Cion-lab commited on
Commit
20765b9
·
verified ·
1 Parent(s): 2002986

build_mix v2: stage->merge interleave + doc offset table; verify_mix asserts source-mix floor

Browse files
Files changed (2) hide show
  1. build/build_mix.py +491 -343
  2. build/verify_mix.py +102 -71
build/build_mix.py CHANGED
@@ -1,343 +1,491 @@
1
- # Phase 2 builder: turn the selected sources into tokenised, deduplicated, resumable shards.
2
- #
3
- # Constraints this is written against (all measured, docs/00-platform-notes.md):
4
- # * Kaggle **CPU** session: 4 vCPU, 30 GiB cgroup, **no swap**, 19.5 GB /kaggle/working. Dedup
5
- # structures are sized by *document count* (~2.1M docs), never by tokens.
6
- # * Hub download is 35-89 MB/s and tokenisation ~661k tok/s on 4 cores. Neither is the bottleneck;
7
- # `datasets` streaming at ~3.3 MB/s per process is. So sources go one at a time, streamed, never
8
- # materialised.
9
- # * The session can be killed at any time (§3.1). Every completed shard is written and recorded before
10
- # the next begins, and the exact-duplicate hash set is persisted alongside it -- otherwise "resume"
11
- # silently means "start forgetting what you already emitted", which would let duplicates back in
12
- # across the resume boundary. State is saved only *after* a shard lands on disk.
13
- #
14
- # Dedup policy, with its limits stated:
15
- # * exact, global: 64-bit hash of the normalised document.
16
- # * cross-source, on the upstream url/id/file_path field -- this is what actually catches FinePhrase's
17
- # configs being rewrites of the same FineWeb-Edu documents (§02-mix-plan §2).
18
- # * near-dup MinHash is NOT run here. It needs every signature resident at once (~1.1 GB) and the
19
- # within-source benefit is already largely captured by the exact hash. It is a v2 candidate, decided
20
- # by the measured exact-dedup loss this script reports, not by convention.
21
- #
22
- # Language: §3.4 is English-only and the inventory measured 8.5% of finepdfs `eng_Latn` and 7.5% of
23
- # open-web-math failing a crude English test, so the filter below is mandatory, not decorative.
24
- #
25
- # No credentials in this file. Publish is a separate script.
26
-
27
- import argparse
28
- import array
29
- import hashlib
30
- import json
31
- import os
32
- import random
33
- import struct
34
- import time
35
-
36
- TOKENS_PER_SHARD = 8_000_000 # 16 MB as uint16 -> ~140 shards for 1.1B tokens, ~2.3 GB total
37
- ROW_TOKEN_CAP = 16_384 # one document must never dominate a shard
38
- SHUFFLE_SEED = 20260919 # recorded in the manifest so a rebuild is reproducible
39
- HASHES_FILE = "seen_hashes.bin"
40
- STATE_NAME = "build_state.json"
41
-
42
- # Corrected config and column names, from the Phase 2 inventory (docs/02-mix-plan.md §9). The §2 names
43
- # came from dataset cards and five of them did not exist.
44
- SOURCES = [
45
- dict(repo="HuggingFaceFW/fineweb-edu", config="sample-10BT", col="text", tokens=300_000_000,
46
- min_score=4, why="quality-scored general English"),
47
- dict(repo="HuggingFaceFW/finepdfs-edu", config="eng_Latn", col="text", tokens=150_000_000,
48
- en_filter=True, why="long-form educational PDFs; needs own en filter"),
49
- dict(repo="HuggingFaceTB/cosmopedia", config="stanford", col="text", tokens=60_000_000,
50
- why="textbook expository"),
51
- dict(repo="HuggingFaceTB/cosmopedia", config="openstax", col="text", tokens=30_000_000,
52
- why="textbook science"),
53
- dict(repo="HuggingFaceTB/cosmopedia", config="khanacademy", col="text", tokens=10_000_000,
54
- why="worked steps"),
55
- dict(repo="HuggingFaceTB/cosmopedia", config="auto_math_text", col="text", tokens=30_000_000,
56
- why="synthetic math exposition"),
57
- dict(repo="HuggingFaceTB/cosmopedia", config="wikihow", col="text", tokens=30_000_000,
58
- why="procedural / physical-world steps"),
59
- dict(repo="HuggingFaceFW/finewiki", config="en", col="rewritten", tokens=80_000_000,
60
- why="rewritten encyclopedic prose"),
61
- dict(repo="omarkamali/wikipedia-monthly", config="20260101.en", col="text", tokens=60_000_000,
62
- why="current encyclopedic breadth"),
63
- dict(repo="HuggingFaceTB/finemath", config="finemath-4plus", col="text", tokens=130_000_000,
64
- why="math; upstream-decontaminated against GSM8K/MMLU/ARC"),
65
- dict(repo="open-web-math/open-web-math", config="default", col="text", tokens=70_000_000,
66
- en_filter=True, why="forum/webbook math prose"),
67
- dict(repo="HuggingFaceFW/finephrase", config="tutorial", col="text", tokens=60_000_000,
68
- why="stepwise tutorial register"),
69
- dict(repo="HuggingFaceFW/finephrase", config="faq", col="text", tokens=30_000_000,
70
- why="FAQ register"),
71
- dict(repo="HuggingFaceFW/finephrase", config="table", col="text", tokens=30_000_000,
72
- why="tabular->prose"),
73
- dict(repo="SimpleStories/SimpleStories", config="default", col="story", tokens=40_000_000,
74
- why="long-range simple narrative coherence"),
75
- dict(repo="common-pile/arxiv_abstracts", config="default", col="text", tokens=20_000_000,
76
- why="CC0 scientific abstracts"),
77
- # No code source yet: stack-v3-train has only a `default` config, and its rows are whole repos with a
78
- # nested `files` struct, so it needs its own probe first (docs/02-mix-plan.md §9.3).
79
- # libretexts dropped outright: its `text` column holds URLs and 93% of sampled rows failed en.
80
- ]
81
-
82
- STOP = (" the ", " and ", " of ", " to ", " that ", " is ", " in ", " for ", " with ", " as ")
83
- KEY_COLS = ("url", "id", "file_path", "doc_id", "generation_id", "repo_name")
84
-
85
-
86
- def normalize(s):
87
- # Whitespace-collapse only. Aggressive folding would collide genuinely different documents (tables,
88
- # code) and a false merge is worse than a missed near-duplicate.
89
- return " ".join(s.split())
90
-
91
-
92
- def looks_english(s):
93
- low = s[:600].lower()
94
- return sum(w in low for w in STOP) >= 3
95
-
96
-
97
- def doc_hash(s):
98
- # digest()[:8] is 8 raw BYTES. hexdigest()[:16] is 16 *hex characters* = 16 bytes, which is what
99
- # the first version sliced and what struct.unpack("<Q") then rejected -- caught by the 12M-token
100
- # smoke test rather than six hours into the full build.
101
- return struct.unpack("<Q", hashlib.sha256(s.encode("utf-8")).digest()[:8])[0]
102
-
103
-
104
- def key_of(row, cols):
105
- for c in KEY_COLS:
106
- if c in cols:
107
- v = row.get(c)
108
- if isinstance(v, str) and v:
109
- return v
110
- return None
111
-
112
-
113
- class State:
114
- def __init__(self, out_dir):
115
- self.dir = out_dir
116
- self.path = os.path.join(out_dir, STATE_NAME)
117
- self.hashes_path = os.path.join(out_dir, HASHES_FILE)
118
- self.d = {"shards": [], "per_source": {}, "started": time.time(), "version": 2}
119
- self.seen_exact = set()
120
- self.seen_url = set()
121
- if os.path.exists(self.path):
122
- self.d = json.load(open(self.path))
123
- if os.path.exists(self.hashes_path):
124
- a = array.array("Q")
125
- with open(self.hashes_path, "rb") as f:
126
- a.fromfile(f, os.path.getsize(self.hashes_path) // 8)
127
- self.seen_exact = set(a)
128
- print(f"resuming: {len(self.d['shards'])} shards, "
129
- f"{len(self.seen_exact):,} exact hashes, {self.tokens_done():,} tokens", flush=True)
130
- for s in self.d["per_source"].values():
131
- self.seen_url.update(s.get("keys", []))
132
-
133
- def tokens_done(self):
134
- return sum(s["tokens"] for s in self.d["shards"])
135
-
136
- def save(self, new_hashes, new_keys):
137
- with open(self.hashes_path, "ab") as f:
138
- array.array("Q", new_hashes).tofile(f)
139
- d = dict(self.d)
140
- d["per_source"] = {k: {kk: vv for kk, vv in v.items() if kk != "keys"}
141
- for k, v in self.d["per_source"].items()}
142
- tmp = self.path + ".tmp"
143
- with open(tmp, "w") as f:
144
- json.dump(d, f, indent=1)
145
- os.replace(tmp, self.path)
146
-
147
- def record_source(self, key, done, tokens, docs_seen, drops, seconds, keys):
148
- self.d["per_source"][key] = {"complete": bool(done), "tokens": tokens,
149
- "docs_seen": docs_seen, "dropped": drops,
150
- "seconds": round(seconds, 1), "keys_sample": keys[:2000]}
151
- self.seen_url.update(keys[:2000])
152
-
153
-
154
- def build(args):
155
- t_start = time.monotonic()
156
- from tokenizers import Tokenizer
157
- from huggingface_hub import hf_hub_download
158
-
159
- os.makedirs(args.out, exist_ok=True)
160
- tok_path = hf_hub_download("HuggingFaceTB/SmolLM2-135M", "tokenizer.json")
161
- tk = Tokenizer.from_file(tok_path)
162
- tk.no_truncation()
163
- vocab = tk.get_vocab_size()
164
- assert vocab <= 65536, f"uint16 shard layout breaks above 65,536 (vocab={vocab})"
165
- tok_sha = hashlib.sha256(open(tok_path, "rb").read()).hexdigest()
166
- print(f"tokenizer vocab={vocab} sha256={tok_sha[:12]} budget={args.budget_tokens:,}", flush=True)
167
-
168
- st = State(args.out)
169
- buf, buf_src = [], []
170
- buf_tokens = 0
171
- pending_hashes, pending_keys = [], []
172
-
173
- def flush_shard():
174
- nonlocal buf, buf_src, buf_tokens, pending_hashes, pending_keys
175
- if not buf:
176
- pending_hashes, pending_keys = [], []
177
- return
178
- ids = array.array("H")
179
- # The buffer deliberately spans source boundaries (nothing flushes at end-of-source), so a shard
180
- # already mixes domains; shuffling the documents first removes the residual "all fineweb-edu, then
181
- # all cosmopedia" ordering inside a pass. Seeded and recorded so a rebuild is reproducible.
182
- random.Random(SHUFFLE_SEED + len(st.d["shards"])).shuffle(buf)
183
- docs = 0
184
- for doc in buf:
185
- ids.extend(doc)
186
- docs += 1
187
- idx = len(st.d["shards"])
188
- path = os.path.join(args.out, f"shard-{idx:04d}.bin")
189
- tmp = path + ".tmp"
190
- with open(tmp, "wb") as f:
191
- f.write(struct.pack("<II", vocab, len(ids)))
192
- ids.tofile(f)
193
- os.replace(tmp, path)
194
- digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
195
- st.d["shards"].append({"file": os.path.basename(path), "tokens": len(ids), "docs": docs,
196
- "sha256": digest, "bytes": os.path.getsize(path),
197
- "sources": sorted(set(buf_src))})
198
- st.save(pending_hashes, pending_keys)
199
- print(f" shard {idx:04d}: {len(ids):,} tok / {docs:,} docs sha256={digest[:12]} "
200
- f"[{time.monotonic() - t_start:.0f}s]", flush=True)
201
- buf, buf_src, buf_tokens, pending_hashes, pending_keys = [], [], 0, [], []
202
-
203
- if args.max_shards and len(st.d["shards"]) >= args.max_shards:
204
- print(f"max_shards={args.max_shards} reached; exiting without work", flush=True)
205
- return {"total_tokens": st.tokens_done(), "shards": len(st.d["shards"])}
206
-
207
- stats = []
208
- for src in SOURCES:
209
- key = f"{src['repo']}::{src['config']}"
210
- if st.d["per_source"].get(key, {}).get("complete"):
211
- print(f"skip {key} (already complete)", flush=True)
212
- continue
213
- if st.tokens_done() >= args.budget_tokens:
214
- print("budget reached", flush=True)
215
- break
216
- if args.max_shards and len(st.d["shards"]) >= args.max_shards:
217
- print(f"max_shards={args.max_shards} reached", flush=True)
218
- break
219
- import datasets
220
-
221
- try:
222
- ds = datasets.load_dataset(src["repo"], src["config"], split="train", streaming=True)
223
- except Exception as e:
224
- # A wrong config name must be loud, must be recorded, and must not abort later sources.
225
- print(f"!! {key} could not load: {type(e).__name__}: {str(e)[:200]}", flush=True)
226
- stats.append({"source": key, "error": f"{type(e).__name__}: {str(e)[:200]}"})
227
- continue
228
-
229
- want = min(src["tokens"], args.budget_tokens - st.tokens_done())
230
- got = docs_seen = 0
231
- drops = {"short_or_low_score": 0, "non_english": 0, "exact_dup": 0, "url_dup": 0, "too_few_tokens": 0}
232
- cols = None
233
- t0 = time.monotonic()
234
- src_keys = []
235
- for row in ds:
236
- if got >= want:
237
- break
238
- if args.sec_per_source and time.monotonic() - t0 > args.sec_per_source:
239
- break
240
- if cols is None:
241
- cols = set(row.keys())
242
- if src["col"] not in cols:
243
- print(f"!! {key} has no column {src['col']!r}; seen {sorted(cols)[:10]}", flush=True)
244
- drops["short_or_low_score"] = -1
245
- break
246
- raw = row.get(src["col"])
247
- if not isinstance(raw, str):
248
- continue
249
- docs_seen += 1
250
- s = normalize(raw)
251
- if len(s) < args.min_chars:
252
- drops["short_or_low_score"] += 1
253
- continue
254
- if src.get("min_score") is not None:
255
- sc = row.get("int_score", row.get("score"))
256
- try:
257
- if sc is not None and float(sc) < src["min_score"]:
258
- drops["short_or_low_score"] += 1
259
- continue
260
- except (TypeError, ValueError):
261
- pass
262
- if src.get("en_filter") and not looks_english(s):
263
- drops["non_english"] += 1
264
- continue
265
- h = doc_hash(s)
266
- if h in st.seen_exact:
267
- drops["exact_dup"] += 1
268
- continue
269
- u = key_of(row, cols)
270
- if u and u in st.seen_url:
271
- drops["url_dup"] += 1
272
- continue
273
- ids = tk.encode(s[:args.max_chars], add_special_tokens=False).ids[:ROW_TOKEN_CAP]
274
- if len(ids) < 16:
275
- drops["too_few_tokens"] += 1
276
- continue
277
- st.seen_exact.add(h)
278
- pending_hashes.append(h)
279
- if u:
280
- st.seen_url.add(u)
281
- src_keys.append(u)
282
- pending_keys.append(u)
283
- buf.append(ids)
284
- buf_src.append(key)
285
- buf_tokens += len(ids)
286
- got += len(ids)
287
- if buf_tokens >= TOKENS_PER_SHARD:
288
- flush_shard()
289
-
290
- elapsed = time.monotonic() - t0
291
- complete = got >= want
292
- st.record_source(key, complete, got, docs_seen, drops, elapsed, src_keys)
293
- stats.append({"source": key, "tokens": got, "docs_seen": docs_seen, "dropped": dict(drops),
294
- "seconds": round(elapsed, 1), "tok_per_s": round(got / max(1e-9, elapsed)),
295
- "complete": bool(complete)})
296
- print(f" {key}: {got:,} tok of {want:,} target from {docs_seen:,} docs; dup={drops['exact_dup']}"
297
- f"/{drops['url_dup']} lang={drops['non_english']} short={drops['short_or_low_score']} "
298
- f"[{stats[-1]['tok_per_s']:,} tok/s, {elapsed:.0f}s]", flush=True)
299
-
300
- flush_shard()
301
- total_docs = sum(s.get("docs_seen", 0) for s in stats) or 1
302
- total_dupes = sum(d["exact_dup"] + d["url_dup"]
303
- for s in stats for d in [s.get("dropped") or {}] if d)
304
- manifest = {
305
- "tokenizer": {"id": "HuggingFaceTB/SmolLM2-135M:tokenizer.json", "vocab_size": vocab,
306
- "sha256": tok_sha},
307
- "layout": {"dtype": "uint16", "header": "<II (vocab, n_tokens)",
308
- "resume_cursor": "(shard_index, token_offset) into the concatenated id stream",
309
- "tokens_per_shard_target": TOKENS_PER_SHARD, "row_token_cap": ROW_TOKEN_CAP,
310
- "shuffle_seed_base": SHUFFLE_SEED,
311
- "shuffle_note": "documents within shard idx are shuffled with seed base+idx; "
312
- "shard order is the training order"},
313
- "total_tokens": st.tokens_done(), "shards": st.d["shards"], "sources": stats,
314
- "budget_tokens": args.budget_tokens,
315
- "exact_dedup_drop_rate": round(total_dupes / total_docs, 4),
316
- "seconds": round(time.monotonic() - t_start, 1),
317
- }
318
- with open(os.path.join(args.out, "manifest.json"), "w") as f:
319
- json.dump(manifest, f, indent=1)
320
- return manifest
321
-
322
-
323
- def main():
324
- ap = argparse.ArgumentParser()
325
- ap.add_argument("--out", default="/kaggle/working/mix")
326
- ap.add_argument("--budget-tokens", type=int, default=1_310_000_000)
327
- ap.add_argument("--min-chars", type=int, default=300)
328
- ap.add_argument("--max-chars", type=int, default=100_000)
329
- ap.add_argument("--sec-per-source", type=int, default=0)
330
- ap.add_argument("--max-shards", type=int, default=0, help="0 = unlimited")
331
- a = ap.parse_args()
332
- if a.max_shards == 0:
333
- a.max_shards = None
334
- m = build(a)
335
- print("BUILD_JSON_BEGIN")
336
- print(json.dumps({"total_tokens": m["total_tokens"], "shards": len(m["shards"]),
337
- "exact_dedup_drop_rate": m.get("exact_dedup_drop_rate"),
338
- "seconds": m["seconds"]}))
339
- print("BUILD_JSON_END")
340
-
341
-
342
- if __name__ == "__main__":
343
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 2 builder: sources -> per-source staged shards -> interleaved mixed shards.
2
+ #
3
+ # Two stages, because one stage cannot produce a mixed corpus without re-streaming.
4
+ #
5
+ # stage per source: stream -> normalise -> language/quality gate -> dedup -> tokenise
6
+ # -> write stage/<key>/shard-####.bin
7
+ # merge read all staged shards together, emit documents round-robin in proportion to each source's
8
+ # target, and write the final mixed mix/shard-####.bin
9
+ #
10
+ # Why the merge exists (memory/ERRORS.md E-016). The first version consumed one source to its target and
11
+ # only then started the next, and it flushed a shard when the buffer filled. Since fineweb-edu's target is
12
+ # 300M tokens = 37 shards of 8M, the first 37 shards of the mix would have been nothing but web text, then
13
+ # textbooks, then math. A shuffle inside the buffer cannot fix that -- the buffer only ever held one
14
+ # source. That is a curriculum drift across the first third of the main run, and no LR schedule repairs
15
+ # ordering like that. Mixing must happen between sources, not inside them.
16
+ #
17
+ # Shard layout is shared by both stages and carries a per-document offset table, so document boundaries
18
+ # survive to the training reader (E-016's second latent bug: the old format stored a doc *count*, from
19
+ # which boundaries cannot be recovered):
20
+ #
21
+ # header : <IIII = vocab, n_docs, n_tokens, flags(0) 16 B
22
+ # offsets : uint32 × (n_docs + 1), cumulative, [0] = 0
23
+ # ids : uint16 × n_tokens
24
+ #
25
+ # doc i of a shard is ids[offsets[i] : offsets[i+1]].
26
+ #
27
+ # Constraints (docs/00-platform-notes.md): Kaggle CPU session, 4 vCPU, 30 GiB cgroup, **no swap**,
28
+ # 19.5 GB /kaggle/working. Staged (~2.6 GB) + final (~2.6 GB) + hash sets (~35 MB) fit with room. Streaming
29
+ # is ~3.3 MB/s per process and tokenising ~661k tok/s on 4 cores, so ingest is serial-per-source by design.
30
+ #
31
+ # Resumability: state is saved only *after* a file lands, and the dedup hash sets are appended in the same
32
+ # transaction -- otherwise a resume forgets what was already emitted and lets duplicates back in across
33
+ # the boundary. No credentials in this file.
34
+
35
+ import argparse
36
+ import array
37
+ import hashlib
38
+ import json
39
+ import os
40
+ import random
41
+ import struct
42
+ import sys
43
+ import time
44
+
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"
51
+ KEYS_FILE = "seen_keys.bin"
52
+ STATE_NAME = "build_state.json"
53
+
54
+ # Corrected config and column names, from the Phase 2 inventory (docs/02-mix-plan.md §9). The names in
55
+ # §2 came from dataset cards, and five of them did not exist.
56
+ SOURCES = [
57
+ dict(repo="HuggingFaceFW/fineweb-edu", config="sample-10BT", col="text", tokens=300_000_000,
58
+ min_score=4, why="quality-scored general English"),
59
+ dict(repo="HuggingFaceFW/finepdfs-edu", config="eng_Latn", col="text", tokens=150_000_000,
60
+ en_filter=True, why="long-form educational PDFs; needs own en filter"),
61
+ dict(repo="HuggingFaceTB/cosmopedia", config="stanford", col="text", tokens=60_000_000,
62
+ why="textbook expository"),
63
+ dict(repo="HuggingFaceTB/cosmopedia", config="openstax", col="text", tokens=30_000_000,
64
+ why="textbook science"),
65
+ dict(repo="HuggingFaceTB/cosmopedia", config="khanacademy", col="text", tokens=10_000_000,
66
+ why="worked steps"),
67
+ dict(repo="HuggingFaceTB/cosmopedia", config="auto_math_text", col="text", tokens=30_000_000,
68
+ why="synthetic math exposition"),
69
+ dict(repo="HuggingFaceTB/cosmopedia", config="wikihow", col="text", tokens=30_000_000,
70
+ why="procedural / physical-world steps"),
71
+ dict(repo="HuggingFaceFW/finewiki", config="en", col="rewritten", tokens=80_000_000,
72
+ why="rewritten encyclopedic prose"),
73
+ dict(repo="omarkamali/wikipedia-monthly", config="20260101.en", col="text", tokens=60_000_000,
74
+ en_filter=True, why="current encyclopedic breadth"),
75
+ dict(repo="HuggingFaceTB/finemath", config="finemath-4plus", col="text", tokens=130_000_000,
76
+ why="math; upstream-decontaminated against GSM8K/MMLU/ARC"),
77
+ dict(repo="open-web-math/open-web-math", config="default", col="text", tokens=70_000_000,
78
+ en_filter=True, why="forum/webbook math prose"),
79
+ dict(repo="HuggingFaceFW/finephrase", config="tutorial", col="text", tokens=60_000_000,
80
+ en_filter=True, why="stepwise tutorial register"),
81
+ dict(repo="HuggingFaceFW/finephrase", config="faq", col="text", tokens=30_000_000,
82
+ en_filter=True, why="FAQ register"),
83
+ dict(repo="HuggingFaceFW/finephrase", config="table", col="text", tokens=30_000_000,
84
+ en_filter=True, why="tabular->prose"),
85
+ dict(repo="SimpleStories/SimpleStories", config="default", col="story", tokens=40_000_000,
86
+ why="long-range simple narrative coherence"),
87
+ dict(repo="common-pile/arxiv_abstracts", config="default", col="text", tokens=20_000_000,
88
+ en_filter=True, why="CC0 scientific abstracts"),
89
+ # No code source yet: stack-v3-train has only a `default` config and its rows are whole repos with a
90
+ # nested files struct, so it needs its own probe (docs/02-mix-plan.md §9.3).
91
+ # common-pile/libretexts dropped: `text` holds URLs, 93% of sampled rows failed the en test.
92
+ ]
93
+
94
+ STOP = (" the ", " and ", " of ", " to ", " that ", " is ", " in ", " for ", " with ", " as ")
95
+ KEY_COLS = ("url", "id", "file_path", "doc_id", "generation_id", "repo_name")
96
+
97
+
98
+ def src_key(src):
99
+ return f"{src['repo'].split('/')[1]}__{src['config']}"
100
+
101
+
102
+ def normalize(s):
103
+ # Whitespace-collapse only: aggressive folding collides genuinely different documents (tables, code),
104
+ # and a false merge is worse than a missed near-duplicate.
105
+ return " ".join(s.split())
106
+
107
+
108
+ def looks_english(s):
109
+ low = s[:600].lower()
110
+ return sum(w in low for w in STOP) >= 3
111
+
112
+
113
+ def h64(s):
114
+ """64-bit digest of a string. digest()[:8] is 8 BYTES; hexdigest()[:16] is 16 *characters*
115
+ and broke the first version (E-015)."""
116
+ return struct.unpack("<Q", hashlib.sha256(s.encode("utf-8")).digest()[:8])[0]
117
+
118
+
119
+ def key_of(row, cols):
120
+ for c in KEY_COLS:
121
+ if c in cols:
122
+ v = row.get(c)
123
+ if isinstance(v, str) and v:
124
+ return v
125
+ return None
126
+
127
+
128
+ # --------------------------------------------------------------------- shard I/O
129
+ def write_shard(path, vocab, docs):
130
+ """docs: iterable of array('H')/list of token ids. Returns (n_docs, n_tokens, sha256, bytes)."""
131
+ offs = array.array("I", [0])
132
+ ids = array.array("H")
133
+ for d in docs:
134
+ ids.extend(d)
135
+ offs.append(len(ids))
136
+ tmp = path + ".tmp"
137
+ with open(tmp, "wb") as f:
138
+ f.write(struct.pack("<IIII", vocab, len(offs) - 1, len(ids), MAGIC_FLAGS))
139
+ offs.tofile(f)
140
+ ids.tofile(f)
141
+ os.replace(tmp, path)
142
+ digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
143
+ return len(offs) - 1, len(ids), digest, os.path.getsize(path)
144
+
145
+
146
+ def read_shard_header(path):
147
+ with open(path, "rb") as f:
148
+ vocab, n_docs, n_tokens, flags = struct.unpack("<IIII", f.read(16))
149
+ return vocab, n_docs, n_tokens, flags
150
+
151
+
152
+ def iter_shard_docs(path):
153
+ """Yield documents as array('H'). Offsets are read in full (4 B/doc) which is fine at 8M tok/shard."""
154
+ with open(path, "rb") as f:
155
+ vocab, n_docs, n_tokens, _flags = struct.unpack("<IIII", f.read(16))
156
+ offs = array.array("I")
157
+ offs.fromfile(f, n_docs + 1)
158
+ ids = array.array("H")
159
+ ids.fromfile(f, n_tokens)
160
+ for i in range(n_docs):
161
+ yield ids[offs[i]:offs[i + 1]]
162
+
163
+
164
+ # --------------------------------------------------------------------- state
165
+ class State:
166
+ def __init__(self, root):
167
+ self.root = root
168
+ self.stage_dir = os.path.join(root, "stage")
169
+ self.mix_dir = os.path.join(root, "mix")
170
+ os.makedirs(self.stage_dir, exist_ok=True)
171
+ os.makedirs(self.mix_dir, exist_ok=True)
172
+ self.path = os.path.join(root, STATE_NAME)
173
+ self.hashes_path = os.path.join(root, HASHES_FILE)
174
+ self.keys_path = os.path.join(root, KEYS_FILE)
175
+ self.d = {"staged": {}, "merged_shards": [], "version": 3}
176
+ self.seen_exact = set()
177
+ self.seen_key = set()
178
+ if os.path.exists(self.path):
179
+ self.d = json.load(open(self.path))
180
+ self.seen_exact = self._load_u64(self.hashes_path)
181
+ self.seen_key = self._load_u64(self.keys_path)
182
+ print(f"resuming: {len(self.d['staged'])} sources staged, "
183
+ f"{len(self.d['merged_shards'])} mixed shards, "
184
+ f"{len(self.seen_exact):,} hashes", flush=True)
185
+
186
+ @staticmethod
187
+ def _load_u64(p):
188
+ if not os.path.exists(p):
189
+ return set()
190
+ a = array.array("Q")
191
+ with open(p, "rb") as f:
192
+ a.fromfile(f, os.path.getsize(p) // 8)
193
+ return set(a)
194
+
195
+ def commit(self, new_hashes, new_keys):
196
+ """Append the dedup sets FIRST, then the state file: if the job dies between the two, the worst
197
+ case is a hash set slightly ahead of the state (a document re-emitted is a duplicate, which the
198
+ next run's dedup catches; a hash set behind the state is a permanent silent duplicate)."""
199
+ if new_hashes:
200
+ with open(self.hashes_path, "ab") as f:
201
+ array.array("Q", new_hashes).tofile(f)
202
+ if new_keys:
203
+ with open(self.keys_path, "ab") as f:
204
+ array.array("Q", new_keys).tofile(f)
205
+ tmp = self.path + ".tmp"
206
+ with open(tmp, "w") as f:
207
+ json.dump(self.d, f, indent=1)
208
+ os.replace(tmp, self.path)
209
+
210
+ def staged_tokens(self, key):
211
+ return sum(s["tokens"] for s in self.d["staged"].get(key, {}).get("shards", []))
212
+
213
+ def staged_tokens_total(self):
214
+ return sum(v.get("tokens", 0) for v in self.d["staged"].values())
215
+
216
+ def merged_tokens(self):
217
+ return sum(s["tokens"] for s in self.d["merged_shards"])
218
+
219
+
220
+ # --------------------------------------------------------------------- stage
221
+ def stage(args, st, tokenizer):
222
+ for src in SOURCES:
223
+ key = src_key(src)
224
+ rec = st.d["staged"].get(key, {})
225
+ if rec.get("complete"):
226
+ print(f"stage: skip {key} (complete)", flush=True)
227
+ continue
228
+ want = min(src["tokens"], args.cap_tokens_per_source or src["tokens"])
229
+ if args.budget_tokens and st.staged_tokens_total() >= args.budget_tokens:
230
+ print("stage: overall budget reached", flush=True)
231
+ break
232
+ import datasets
233
+
234
+ try:
235
+ ds = datasets.load_dataset(src["repo"], src["config"], split="train", streaming=True)
236
+ except Exception as e:
237
+ print(f"!! stage: {key} could not load: {type(e).__name__}: {str(e)[:200]}", flush=True)
238
+ rec["error"] = f"{type(e).__name__}: {str(e)[:200]}"
239
+ st.d["staged"][key] = rec
240
+ st.commit([], [])
241
+ continue
242
+
243
+ got = docs_seen = 0
244
+ drops = {"short_or_low_score": 0, "non_english": 0, "exact_dup": 0, "key_dup": 0,
245
+ "too_few_tokens": 0, "no_column": 0}
246
+ cols = None
247
+ buf, new_h, new_k = [], [], []
248
+ sd = os.path.join(st.stage_dir, key)
249
+ os.makedirs(sd, exist_ok=True)
250
+ shards = rec.get("shards", [])
251
+ t0 = time.monotonic()
252
+ for row in ds:
253
+ if got >= want:
254
+ break
255
+ if args.sec_per_source and time.monotonic() - t0 > args.sec_per_source:
256
+ break
257
+ if cols is None:
258
+ cols = set(row.keys())
259
+ if src["col"] not in cols:
260
+ print(f"!! stage: {key} has no column {src['col']!r}; seen {sorted(cols)[:10]}",
261
+ flush=True)
262
+ drops["no_column"] = -1
263
+ break
264
+ raw = row.get(src["col"])
265
+ if not isinstance(raw, str):
266
+ continue
267
+ docs_seen += 1
268
+ s = normalize(raw)
269
+ if len(s) < args.min_chars:
270
+ drops["short_or_low_score"] += 1
271
+ continue
272
+ if src.get("min_score") is not None:
273
+ sc = row.get("int_score", row.get("score"))
274
+ try:
275
+ if sc is not None and float(sc) < src["min_score"]:
276
+ drops["short_or_low_score"] += 1
277
+ continue
278
+ except (TypeError, ValueError):
279
+ pass
280
+ if src.get("en_filter") and not looks_english(s):
281
+ drops["non_english"] += 1
282
+ continue
283
+ h = h64(s)
284
+ if h in st.seen_exact:
285
+ drops["exact_dup"] += 1
286
+ continue
287
+ u = key_of(row, cols)
288
+ ku = h64(u) if u else None
289
+ if ku is not None and ku in st.seen_key:
290
+ drops["key_dup"] += 1
291
+ continue
292
+ ids = tokenizer.encode(s[:args.max_chars], add_special_tokens=False).ids[:ROW_TOKEN_CAP]
293
+ if len(ids) < 16:
294
+ drops["too_few_tokens"] += 1
295
+ continue
296
+ st.seen_exact.add(h)
297
+ new_h.append(h)
298
+ if ku is not None:
299
+ st.seen_key.add(ku)
300
+ new_k.append(ku)
301
+ buf.append(array.array("H", ids))
302
+ got += len(ids)
303
+ if sum(len(b) for b in buf) >= TOKENS_PER_SHARD:
304
+ idx = len(shards)
305
+ p = os.path.join(sd, f"shard-{idx:04d}.bin")
306
+ nd, nt, dg, bs = write_shard(p, tokenizer.get_vocab_size(), buf)
307
+ shards.append({"file": os.path.relpath(p, st.root), "docs": nd, "tokens": nt,
308
+ "sha256": dg, "bytes": bs})
309
+ st.seen_exact.update(new_h)
310
+ st.d["staged"][key] = {"complete": False, "shards": shards, "tokens": got,
311
+ "docs_seen": docs_seen, "dropped": drops}
312
+ st.commit(new_h, new_k)
313
+ new_h, new_k = [], []
314
+ buf = []
315
+ print(f" staged {key} shard {idx:04d}: {nt:,} tok [{time.monotonic() - t0:.0f}s]",
316
+ flush=True)
317
+
318
+ if buf:
319
+ idx = len(shards)
320
+ p = os.path.join(sd, f"shard-{idx:04d}.bin")
321
+ nd, nt, dg, bs = write_shard(p, tokenizer.get_vocab_size(), buf)
322
+ shards.append({"file": os.path.relpath(p, st.root), "docs": nd, "tokens": nt,
323
+ "sha256": dg, "bytes": bs})
324
+ print(f" staged {key} shard {idx:04d}: {nt:,} tok (tail)", flush=True)
325
+
326
+ complete = got >= want
327
+ st.d["staged"][key] = {"complete": bool(complete), "shards": shards, "tokens": got,
328
+ "docs_seen": docs_seen, "dropped": dict(drops),
329
+ "target": want, "seconds": round(time.monotonic() - t0, 1)}
330
+ st.commit(new_h, new_k)
331
+ el = time.monotonic() - t0
332
+ print(f"stage: {key} {got:,} tok of {want:,} from {docs_seen:,} docs; "
333
+ f"dup={drops['exact_dup']} keydup={drops['key_dup']} lang={drops['non_english']} "
334
+ f"short={drops['short_or_low_score']} [{got / max(1e-9, el):,.0f} tok/s]", flush=True)
335
+
336
+
337
+ # --------------------------------------------------------------------- merge
338
+ def merge(args, st, tokenizer):
339
+ """Interleave staged documents proportionally to each source's staged token total."""
340
+ vocab = tokenizer.get_vocab_size()
341
+ streams = []
342
+ for src in SOURCES:
343
+ key = src_key(src)
344
+ rec = st.d["staged"].get(key)
345
+ if not rec or not rec.get("shards"):
346
+ continue
347
+ weight = rec.get("tokens") or sum(s["tokens"] for s in rec["shards"])
348
+ if weight <= 0:
349
+ continue
350
+ iters = [iter_shard_docs(os.path.join(st.root, s["file"])) for s in rec["shards"]]
351
+
352
+ def gen(its=iters):
353
+ for it in its:
354
+ for d in it:
355
+ yield d
356
+
357
+ streams.append({"key": key, "weight": float(weight), "credit": 0.0, "it": gen(iters),
358
+ "pending": None, "emitted_tokens": 0, "exhausted": False})
359
+ if not streams:
360
+ print("merge: nothing staged", flush=True)
361
+ return
362
+ total_w = sum(s["weight"] for s in streams)
363
+ print(f"merge: {len(streams)} sources, "
364
+ f"{sum(s['weight'] for s in streams) / 1e6:,.0f}M staged tokens", flush=True)
365
+
366
+ def next_doc(s):
367
+ if s["pending"] is None and not s["exhausted"]:
368
+ try:
369
+ s["pending"] = next(s["it"])
370
+ except StopIteration:
371
+ s["exhausted"] = True
372
+ return s["pending"]
373
+
374
+ cap = args.merge_budget_tokens or int(total_w)
375
+ buf, buf_src, buf_tokens = [], [], 0
376
+ out_dir = os.path.join(st.root, "mix")
377
+ os.makedirs(out_dir, exist_ok=True)
378
+ t0 = time.monotonic()
379
+
380
+ def flush():
381
+ nonlocal buf, buf_src, buf_tokens
382
+ if not buf:
383
+ return
384
+ random.Random(SHUFFLE_SEED + len(st.d["merged_shards"])).shuffle(buf)
385
+ docs = [d for d, _ in buf]
386
+ p = os.path.join(out_dir, f"shard-{len(st.d['merged_shards']):04d}.bin")
387
+ nd, nt, dg, bs = write_shard(p, vocab, docs)
388
+ st.d["merged_shards"].append({"file": os.path.relpath(p, st.root), "docs": nd, "tokens": nt,
389
+ "sha256": dg, "bytes": bs,
390
+ "sources": sorted(set(s for _, s in buf))})
391
+ st.commit([], [])
392
+ print(f" mix shard {len(st.d['merged_shards']) - 1:04d}: {nt:,} tok / {nd:,} docs "
393
+ f"from {len(st.d['merged_shards'][-1]['sources'])} sources "
394
+ f"[{time.monotonic() - t0:.0f}s]", flush=True)
395
+ buf, buf_src, buf_tokens = [], [], 0
396
+
397
+ while st.merged_tokens() + buf_tokens < cap:
398
+ live = [s for s in streams if next_doc(s) is not None]
399
+ if not live:
400
+ break
401
+ # Largest-deficit scheduling: whoever is furthest below its proportional share of what has been
402
+ # emitted goes next. O(n_sources) per batch of MERGE_BATCH_DOCS, not per document.
403
+ emitted = sum(s["emitted_tokens"] for s in streams) or 1
404
+ pick = max(live, key=lambda s: s["weight"] / total_w - s["emitted_tokens"] / emitted)
405
+ took = 0
406
+ while took < MERGE_BATCH_DOCS:
407
+ d = next_doc(pick)
408
+ if d is None:
409
+ break
410
+ buf.append((d, pick["key"]))
411
+ buf_tokens += len(d)
412
+ pick["emitted_tokens"] += len(d)
413
+ pick["pending"] = None
414
+ took += 1
415
+ if buf_tokens >= TOKENS_PER_SHARD:
416
+ flush()
417
+ break
418
+ if all(s["pending"] is None and s["exhausted"] for s in streams):
419
+ break
420
+ flush()
421
+ print(f"merge: {len(st.d['merged_shards'])} shards, "
422
+ f"{sum(s['tokens'] for s in st.d['merged_shards']):,} tokens", flush=True)
423
+
424
+
425
+ # --------------------------------------------------------------------- entry
426
+ def main():
427
+ ap = argparse.ArgumentParser()
428
+ ap.add_argument("--root", default="/kaggle/working/mixroot")
429
+ ap.add_argument("--budget-tokens", type=int, default=1_310_000_000, help="stage cap")
430
+ ap.add_argument("--merge-budget-tokens", type=int, default=0, help="0 = take everything staged")
431
+ ap.add_argument("--cap-tokens-per-source", type=int, default=0, help="smoke test knob")
432
+ ap.add_argument("--min-chars", type=int, default=300)
433
+ ap.add_argument("--max-chars", type=int, default=100_000)
434
+ ap.add_argument("--sec-per-source", type=int, default=0)
435
+ ap.add_argument("--only", default="", help="comma list of config names to stage")
436
+ ap.add_argument("--stage-only", action="store_true")
437
+ ap.add_argument("--merge-only", action="store_true")
438
+ args = ap.parse_args()
439
+
440
+ if args.only:
441
+ keep = set(args.only.split(","))
442
+ SOURCES[:] = [s for s in SOURCES if s["config"] in keep or src_key(s) in keep]
443
+ print(f"--only: {len(SOURCES)} source(s)", flush=True)
444
+
445
+ from tokenizers import Tokenizer
446
+ from huggingface_hub import hf_hub_download
447
+ tok_path = hf_hub_download("HuggingFaceTB/SmolLM2-135M", "tokenizer.json")
448
+ tk = Tokenizer.from_file(tok_path)
449
+ tk.no_truncation()
450
+ assert tk.get_vocab_size() <= 65536, "uint16 shard layout breaks above 65,536"
451
+ print(f"tokenizer vocab={tk.get_vocab_size()} "
452
+ f"sha256={hashlib.sha256(open(tok_path, 'rb').read()).hexdigest()[:12]}", flush=True)
453
+
454
+ st = State(args.root)
455
+ t0 = time.monotonic()
456
+ if not args.merge_only:
457
+ stage(args, st, tk)
458
+ if not args.stage_only:
459
+ merge(args, st, tk)
460
+
461
+ manifest = {
462
+ "tokenizer": {"id": "HuggingFaceTB/SmolLM2-135M:tokenizer.json",
463
+ "vocab_size": tk.get_vocab_size(),
464
+ "sha256": hashlib.sha256(open(tok_path, "rb").read()).hexdigest()},
465
+ "layout": {"dtype": "uint16", "header": "<IIII (vocab, n_docs, n_tokens, flags)",
466
+ "offsets": "uint32 × (n_docs+1) cumulative",
467
+ "doc_slice": "ids[offsets[i]:offsets[i+1]]",
468
+ "tokens_per_shard_target": TOKENS_PER_SHARD, "row_token_cap": ROW_TOKEN_CAP,
469
+ "merge_batch_docs": MERGE_BATCH_DOCS, "shuffle_seed_base": SHUFFLE_SEED,
470
+ "resume_cursor": "(shard_index, token_offset) into the concatenated id stream"},
471
+ "stage": {k: {kk: vv for kk, vv in v.items() if kk != "shards"}
472
+ for k, v in st.d["staged"].items()},
473
+ "staged_tokens": st.staged_tokens_total(),
474
+ "shards": st.d["merged_shards"],
475
+ "total_tokens": sum(s["tokens"] for s in st.d["merged_shards"]),
476
+ "distinct_sources_per_shard_min": min((len(s["sources"]) for s in st.d["merged_shards"]),
477
+ default=0),
478
+ "seconds": round(time.monotonic() - t0, 1),
479
+ }
480
+ with open(os.path.join(args.root, "manifest.json"), "w") as f:
481
+ json.dump(manifest, f, indent=1)
482
+ print("BUILD_JSON_BEGIN")
483
+ print(json.dumps({k: manifest[k] for k in
484
+ ("staged_tokens", "total_tokens", "distinct_sources_per_shard_min",
485
+ "seconds")}, indent=1))
486
+ print("shards=", len(manifest["shards"]), flush=True)
487
+ print("BUILD_JSON_END")
488
+
489
+
490
+ if __name__ == "__main__":
491
+ main()
build/verify_mix.py CHANGED
@@ -1,19 +1,18 @@
1
- # Gate 2 verification: recount the mix from the published artifacts, independently of the builder.
2
  #
3
- # Why this is a separate script rather than a few lines in the builder. §Phase 2's gate says the token
4
- # count must be "verified against the manifest" -- and a number checked by the code that produced it
5
- # proves nothing about a truncated write, a bad header, or a source that silently contributed nothing.
6
- # This script re-derives everything from the shard files alone:
 
 
 
 
 
 
 
7
  #
8
- # 1. every shard's sha256 matches the manifest (catches a corrupted or partial upload),
9
- # 2. the header's declared token count equals the byte length (catches a torn write),
10
- # 3. every id is < vocab, i.e. no out-of-range token would break an embedding lookup at train time,
11
- # 4. total tokens recomputed by reading, not by summing the builder's counters,
12
- # 5. decoded samples are plausible English, and per-source mix is not degenerate,
13
- # 6. the concatenated stream is re-read at random offsets to prove the (shard_index, token_offset)
14
- # cursor actually addresses what it claims -- which is the property §3.1's exact resume depends on.
15
- #
16
- # CPU session; reads only; writes nothing. No credentials.
17
 
18
  import argparse
19
  import array
@@ -24,7 +23,7 @@ import random
24
  import struct
25
 
26
 
27
- def sha256_file(path, chunk=4 * 1024**2):
28
  h = hashlib.sha256()
29
  with open(path, "rb") as f:
30
  for b in iter(lambda: f.read(chunk), b""):
@@ -34,26 +33,33 @@ def sha256_file(path, chunk=4 * 1024**2):
34
 
35
  def read_shard(path):
36
  with open(path, "rb") as f:
37
- head = f.read(8)
38
- vocab, n = struct.unpack("<II", head)
39
- a = array.array("H")
40
- a.fromfile(f, n)
 
 
 
 
41
  trailing = len(f.read())
42
- return vocab, n, a, trailing
 
43
 
44
 
45
  def main():
46
  ap = argparse.ArgumentParser()
47
- ap.add_argument("--dir", default="/kaggle/working/mix")
48
  ap.add_argument("--sample-decodes", type=int, default=6)
49
  ap.add_argument("--cursor-probes", type=int, default=40)
 
 
50
  a = ap.parse_args()
51
 
52
- man = json.load(open(os.path.join(a.dir, "manifest.json")))
53
- tk_path = man["tokenizer"].get("local_path")
54
- out = {"shards_expected": len(man["shards"]), "sha_ok": 0, "sha_bad": [], "header_ok": 0,
55
- "header_bad": [], "out_of_range_ids": 0, "total_tokens_recounted": 0,
56
- "total_docs_recounted": 0, "trailing_bytes": 0}
57
 
58
  tok = None
59
  try:
@@ -64,77 +70,102 @@ def main():
64
  except Exception as e:
65
  out["tokenizer_note"] = f"decode checks disabled: {type(e).__name__}: {e}"[:160]
66
 
67
- vocab = man["tokenizer"]["vocab_size"]
68
  sizes = []
69
  for rec in man["shards"]:
70
- p = os.path.join(a.dir, rec["file"])
71
  if not os.path.exists(p):
72
  out["header_bad"].append({"file": rec["file"], "error": "missing"})
73
  continue
74
- got_sha = sha256_file(p)
75
- if got_sha == rec["sha256"]:
76
- out["sha_ok"] += 1
77
- else:
78
- out["sha_bad"].append({"file": rec["file"], "want": rec["sha256"][:12],
79
- "got": got_sha[:12]})
80
  continue
81
- v, n, ids, trailing = read_shard(p)
82
- sizes.append((rec["file"], n))
83
- if v != vocab or trailing != 0 or n != rec["tokens"]:
84
- out["header_bad"].append({"file": rec["file"], "vocab": v, "n": n,
85
- "manifest_n": rec["tokens"], "trailing": trailing})
 
 
 
 
 
 
 
86
  else:
87
  out["header_ok"] += 1
88
- out["out_of_range_ids"] += sum(1 for i in ids if i >= vocab)
89
- out["total_tokens_recounted"] += n
90
- out["total_docs_recounted"] += rec["docs"]
91
- out["trailing_bytes"] += trailing
92
-
93
- # Where each shard's tokens came from, from the manifest, checked for a degenerate mix.
94
- per_src = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  for rec in man["shards"]:
 
96
  for s in rec["sources"]:
97
- per_src[s] = per_src.get(s, 0) + rec["tokens"]
98
- out["tokens_by_source_from_manifest"] = {k: v for k, v in sorted(per_src.items())}
99
- out["distinct_sources_per_shard_min"] = min((len(r["sources"]) for r in man["shards"]), default=0)
 
100
 
101
  if tok is not None and sizes:
102
- rng = random.Random(7)
103
  decs = []
104
  for _ in range(a.sample_decodes):
105
  fname, n = rng.choice(sizes)
106
- v, nn, ids, _t = read_shard(os.path.join(a.dir, fname))
107
- off = rng.randrange(0, max(1, nn - 200))
108
- decs.append({"shard": fname, "offset": off,
109
- "text": tok.decode(list(ids[off:off + 200]))[:220]})
110
- out["decoded_samples"] = decs
111
-
112
- # Prove the cursor contract: (shard_index, token_offset) must land on the same ids every time,
113
- # and re-reading a span from the concatenated stream must equal re-reading it shard-by-shard.
114
- probes = 0
115
- mismatches = 0
116
  total = sum(n for _f, n in sizes)
117
  for _ in range(a.cursor_probes):
118
- gpos = rng.randrange(0, max(1, total - 64))
119
  acc, si, lo = 0, 0, 0
120
  for i, (_f, n) in enumerate(sizes):
121
- if acc + n > gpos:
122
- si, lo = i, gpos - acc
123
  break
124
  acc += n
125
- v, n, ids, _t = read_shard(os.path.join(a.dir, sizes[si][0]))
126
- if lo + 64 > n:
 
127
  continue
128
  probes += 1
129
- v2, n2, ids2, _t2 = read_shard(os.path.join(a.dir, sizes[si][0]))
130
- if list(ids[lo:lo + 64]) != list(ids2[lo:lo + 64]):
131
- mismatches += 1
132
- out["cursor_probes"] = {"done": probes, "mismatches": mismatches}
 
 
 
 
 
133
 
134
  out["manifest_claims_total_tokens"] = man["total_tokens"]
135
  out["recount_matches_manifest"] = man["total_tokens"] == out["total_tokens_recounted"]
136
- out["PASS"] = (not out["sha_bad"] and not out["header_bad"] and out["recount_matches_manifest"]
137
- and out["out_of_range_ids"] == 0
 
138
  and out.get("cursor_probes", {}).get("mismatches", 1) == 0)
139
  print("VERIFY_JSON_BEGIN")
140
  print(json.dumps(out, indent=1, default=str))
 
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
 
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""):
 
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:
 
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))