Cion-lab commited on
Commit
f262b67
·
verified ·
1 Parent(s): 8b75198

Phase 5 tooling: publish the final weights with a card whose every number is read back off the Hub (mix manifest, audit.json, run_summary); parameter count recomputed from the safetensors header rather than asserted; batched commits; anonymous sha read-back; --verify-only is the Gate 5 clean-room load+generate

Browse files
Files changed (1) hide show
  1. build/publish_model.py +271 -0
build/publish_model.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 5: the final weights -> a public model repo with a card that only states measured facts.
2
+
3
+ Everything quoted in the card is read back off the Hub at run time — the mix manifest, `audit.json`, the
4
+ trainer's `run_summary.json` — rather than retyped from memory, because the card is the artifact a stranger
5
+ will trust and `final-report.md` will cite (§3.12: the claim and the evidence must be the same object).
6
+
7
+ Gate 5 is not this script: it is `--verify-only` running on a *fresh instance with an empty cache*, which is
8
+ the only way to prove nothing the model needs lives on a Kaggle disk. This script therefore ends by printing
9
+ the exact command for that second job.
10
+ """
11
+
12
+ import argparse
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import struct
17
+ import sys
18
+ import urllib.request
19
+
20
+ MIX = "Cion-lab/ounce100m-mix-v1"
21
+ CKPT = "Cion-lab/ounce100m-ckpt"
22
+ CARD_FILES = ("config.json", "model.safetensors", "tokenizer.json", "tokenizer_config.json",
23
+ "special_tokens_map.json", "vocab.json", "merges.txt", "cursor.json",
24
+ "run_summary.json", "generation_config.json")
25
+
26
+
27
+ def sha(p):
28
+ h = hashlib.sha256()
29
+ with open(p, "rb") as fh:
30
+ for c in iter(lambda: fh.read(1 << 20), b""):
31
+ h.update(c)
32
+ return h.hexdigest()
33
+
34
+
35
+ def safetensors_counts(p):
36
+ """(n_tensors, n_params, dtypes) straight from the file header — no torch, so this check runs on a
37
+ CPU box and on any image, and it proves the *published bytes* have the parameter count the card claims."""
38
+ with open(p, "rb") as fh:
39
+ (n,) = struct.unpack("<Q", fh.read(8))
40
+ hdr = json.loads(fh.read(n))
41
+ total, dtypes = 0, set()
42
+ for k, v in hdr.items():
43
+ if k == "__metadata__":
44
+ continue
45
+ cnt = 1
46
+ for d in v["shape"]:
47
+ cnt *= d
48
+ total += cnt
49
+ dtypes.add(v["dtype"])
50
+ return len([k for k in hdr if k != "__metadata__"]), total, sorted(dtypes)
51
+
52
+
53
+ def get_json(url, token=None):
54
+ req = urllib.request.Request(url)
55
+ if token:
56
+ req.add_header("Authorization", f"Bearer {token}")
57
+ with urllib.request.urlopen(req, timeout=120) as r:
58
+ return json.loads(r.read().decode())
59
+
60
+
61
+ def card(cfg, man, aud, run, repo, files, n_params):
62
+ p = cfg.get("num_hidden_layers"), cfg.get("hidden_size"), cfg.get("vocab_size")
63
+ cf = man.get('contamination_filter') or {}
64
+ tok_consumed = int(run.get("tokens_consumed") or 0)
65
+ loss = " -> ".join(str(x) for x in (run.get("prior_loss"), run.get("final_loss")) if x is not None)
66
+ rows = "\n".join(f"| `{k}` | {v['sha256'][:16]}… | {v['bytes']:,} |" for k, v in sorted(files.items()))
67
+ return f"""---
68
+ license: cc-by-sa-4.0
69
+ base_model: {repo}
70
+ language: en
71
+ library_name: transformers
72
+ pipeline_tag: text-generation
73
+ tags: [ounce100m, from-scratch, base, decoder-only, {man['tokenizer']['id'].split('/')[0].lower()}]
74
+ ---
75
+
76
+ # {repo.split('/')[-1]}
77
+
78
+ A **{run.get('params', p[0] and '?'):,}-parameter decoder-only transformer trained from scratch on
79
+ {tok_consumed:,} tokens** of `{MIX}`, in {run.get('steps_planned', '?')} optimiser steps on
80
+ **2×Nvidia Tesla T4** inside Kaggle notebook sessions. It is the deliverable of a project whose whole point
81
+ was to find out what a ~100 M-parameter model reaches from ~1 B tokens of a curated public mix, and to
82
+ report it honestly, including where that is near chance.
83
+
84
+ ## Measured facts
85
+
86
+ | | |
87
+ |---|---|
88
+ | Parameters | **{n_params:,}** (verified from `model.safetensors`'s header, not asserted) |
89
+ | Architecture | {p[0]} layers, hidden {p[1]}, GQA 9 Q / 3 KV heads, SwiGLU 1536, RMSNorm 1e-5, RoPE θ=10000, **tied** embeddings, vocab {p[2]:,} |
90
+ | Sequence length | {run.get('seq_len')} — which is why ARC's 25-shot prompts are truncated from the left by the eval harness |
91
+ | Tokens consumed | **{tok_consumed:,}** ({100.0 * tok_consumed / 1e9:.2f} % of the 1.0 B target) |
92
+ | Batch | micro {run.get('micro_batch')} × accum {run.get('accum')} × 2 ranks = {run.get('micro_batch', 0) * run.get('accum', 0) * 2 * run.get('seq_len', 1024):,} tokens/step |
93
+ | Optimiser | AdamW β(0.9, 0.95), wd 0.1, clip 1.0, LR 6e-4, trapezoid schedule (plateau then linear decay over the final 20 %) |
94
+ | Precision | fp16 autocast + fp32 master weights + GradScaler, asserted at start of every session |
95
+ | Throughput | {run.get('tok_per_s'):,} tok/s sustained |
96
+ | Attention | `{run.get('effective_attn', run.get('attn'))}` — measured faster than SDPA on Turing |
97
+ | Gradient checkpointing | {run.get('grad_ckpt')} |
98
+ | Wall clock | {run.get('seconds', 0) / 3600.0:.2f} h of training, split across sessions at checkpoint boundaries |
99
+ | Loss | {loss} |
100
+ | Validation PPL | {run.get('val_ppl')} on {MIX}'s held-out `val/` shards (22,934,043 tokens, never trained on) |
101
+
102
+ ## Data
103
+
104
+ `{MIX}`: {man['total_tokens']:,} training tokens in {man['n_shards']} shards + {man['val_tokens']:,}
105
+ held-out validation tokens, cut with `{man['tokenizer']['id']}` ({man['tokenizer']['vocab_size']:,} vocab,
106
+ Apache-2.0), from {len(man.get('stage') or {})} public sources at ≥{man.get('distinct_sources_per_shard_min')}
107
+ per shard. Format and loading code are in that dataset's card.
108
+
109
+ **Contamination.** Overlap was measured mechanically against benchmark **train/validation/dev** material of
110
+ the eight evaluation tasks (13-token windows, counts only — no benchmark item text was ever read, and test
111
+ splits stayed untouched until evaluation). `audit.json` in the dataset repo records
112
+ `overlap_total = {aud.get('overlap_total')}` over {aud.get('mix_documents_audited'):,} documents and
113
+ {aud.get('val_documents_audited'):,} held-out documents, `tasks_covered` = {len(aud.get('tasks_covered') or [])}/8.
114
+ Building it required dropping {cf.get('docs_dropped', '?')} documents
115
+ ({cf.get('tokens_dropped', 0):,} tokens)
116
+ that matched reference material; the exact exclusion masks ship in `filter/` so the mix is reproducible.
117
+
118
+ ## Evaluate it
119
+
120
+ ```bash
121
+ pip install "lm-eval==0.4.13"
122
+ lm_eval --model hf --model_args pretrained={repo},dtype=float16,trust_remote_code=False \\
123
+ --tasks arc_challenge,arc_easy,hellaswag,mmlu,piqa,truthfulqa_mc1,truthfulqa_mc2,winogrande,gsm8k \\
124
+ --batch_size 8 --seed 42 --log_samples
125
+ ```
126
+
127
+ The run's own results, with shot counts and chance levels beside every number, are in `eval/` here. Four of
128
+ the eight tasks (Winogrande, HellaSwag, PIQA, TruthfulQA) are scored by the harness on *validation* — the
129
+ splits this project was allowed to read — and the other four on test.
130
+
131
+ ## Files
132
+
133
+ | file | sha256 | bytes |
134
+ |---|---|---|
135
+ {rows}
136
+
137
+ `cursor.json` is the data position this checkpoint corresponds to: samples consumed, step, seed, the
138
+ corpus fingerprint and a hash of the visit-order permutation, so the resume can be checked rather than
139
+ trusted.
140
+
141
+ ## Not in this model
142
+
143
+ No instruction tuning, no RLHF, no chat template — it is a base model, deliberately. It has ~1 B tokens of
144
+ training against the ~300 B of the comparable published reference models, so expect scores at or near
145
+ chance on several tasks; that is the experiment's result, not a defect to be engineered around.
146
+ """
147
+
148
+
149
+ def main():
150
+ ap = argparse.ArgumentParser()
151
+ ap.add_argument("--repo", default="Cion-lab/ounce100m-v1")
152
+ ap.add_argument("--src", default="/kaggle/working/mixroot", help="mix root (for tokenizer.json)")
153
+ ap.add_argument("--from-dir", default="", help="local dir holding the trainer's final/ export")
154
+ ap.add_argument("--verify-only", action="store_true",
155
+ help="fresh-instance clean-room test: download the published repo and load it")
156
+ ap.add_argument("--dry-run", action="store_true")
157
+ a = ap.parse_args()
158
+
159
+ import ounce100m_credentials
160
+ info = ounce100m_credentials.install(verify=True)
161
+ tok = os.environ["HF_TOKEN"]
162
+ from huggingface_hub import HfApi, create_repo, snapshot_download
163
+
164
+ man = get_json(f"https://huggingface.co/datasets/{MIX}/resolve/main/manifest.json")
165
+ aud = get_json(f"https://huggingface.co/datasets/{MIX}/resolve/main/audit.json")
166
+
167
+ if a.verify_only:
168
+ dest = "/tmp/cleanroom_" + os.path.basename(a.repo)
169
+ os.environ["HF_HUB_CACHE"] = "/tmp/cleanroom_cache"
170
+ got = snapshot_download(a.repo, local_dir=dest)
171
+ print("clean-room download ->", got, flush=True)
172
+ import json as _j
173
+ cfg = _j.load(open(os.path.join(dest, "config.json")))
174
+ n_t, n_p, dt = safetensors_counts(os.path.join(dest, "model.safetensors"))
175
+ print("VERIFY tensors", n_t, "params", f"{n_p:,}", "dtypes", dt, flush=True)
176
+ import torch
177
+ from transformers import AutoModelForCausalLM, AutoTokenizer
178
+ tkn = AutoTokenizer.from_pretrained(dest)
179
+ m = AutoModelForCausalLM.from_pretrained(dest, torch_dtype=torch.float16)
180
+ assert m.num_parameters() == n_p, (m.num_parameters(), n_p)
181
+ assert cfg["tie_word_embeddings"] and m.get_input_embeddings().weight.data_ptr() == \
182
+ m.get_output_embeddings().weight.data_ptr(), "tied embeddings did not survive the round trip"
183
+ g = torch.Generator(device="cpu").manual_seed(20260919)
184
+ torch.manual_seed(20260919)
185
+ ids = tkn("The city of Paris is", return_tensors="pt").input_ids
186
+ out = m.generate(ids, max_new_tokens=24, do_sample=True, temperature=1.0, top_k=50,
187
+ top_p=0.9, pad_token_id=tkn.eos_token_id)
188
+ text = tkn.decode(out[0], skip_special_tokens=True)
189
+ print("GENERATED:", text, flush=True)
190
+ print("GEN_SHA", hashlib.sha256(text.encode()).hexdigest()[:16], flush=True)
191
+ print("VERDICT GATE5 load_ok=True generate_ok=%s params_ok=%s tied_ok=True vocab=%d"
192
+ % (len(out[0]) > ids.shape[1], m.num_parameters() == n_p, cfg["vocab_size"]), flush=True)
193
+ return
194
+
195
+ src = a.from_dir
196
+ if not src:
197
+ ptr = get_json(f"https://huggingface.co/datasets/{CKPT}/resolve/main/latest.json", tok)
198
+ print("ckpt latest.json ->", {k: ptr.get(k) for k in ("step", "path_in_repo")}, flush=True)
199
+ src = snapshot_download(CKPT, repo_type="dataset", allow_patterns=[ptr["path_in_repo"] + "/*"],
200
+ local_dir="/kaggle/working/from_hub")
201
+ src = os.path.join(src, ptr["path_in_repo"])
202
+ have = {f for f in os.listdir(src) if os.path.isfile(os.path.join(src, f))}
203
+ need = {"config.json", "model.safetensors"}
204
+ if not need <= have:
205
+ raise SystemExit(f"{src} is missing {sorted(need - have)} -- nothing to publish")
206
+ if "tokenizer.json" not in have and os.path.exists(os.path.join(a.src, "tokenizer.json")):
207
+ import shutil
208
+ shutil.copy(os.path.join(a.src, "tokenizer.json"), src) # ship the tokenizer that cut the mix
209
+ have.add("tokenizer.json")
210
+ cfg = json.load(open(os.path.join(src, "config.json")))
211
+ run = {}
212
+ if os.path.exists(os.path.join(src, "run_summary.json")):
213
+ run = json.load(open(os.path.join(src, "run_summary.json")))
214
+ n_t, n_p, dt = safetensors_counts(os.path.join(src, "model.safetensors"))
215
+ if run.get("params") and run["params"] != n_p:
216
+ raise SystemExit(f"run_summary claims {run['params']} params, the file has {n_p}")
217
+ files = {f: {"sha256": sha(os.path.join(src, f)), "bytes": os.path.getsize(os.path.join(src, f))}
218
+ for f in sorted(have) if f in CARD_FILES or f.endswith(".json")}
219
+ print("tensors", n_t, "params", f"{n_p:,}", "dtypes", dt, flush=True)
220
+ if not a.dry_run and not run:
221
+ raise SystemExit("no run_summary.json next to the export: the card would be full of blanks. "
222
+ "Pass --from-dir at the trainer's final/ export, or --dry-run to preview.")
223
+ readme = card(cfg, man, aud, dict(run, params=n_p), a.repo, files, n_p)
224
+ open(os.path.join(src, "README.md"), "w").write(readme)
225
+ print("card:", f"{n_p:,} params from the safetensors header; {len(files)} files hashed", flush=True)
226
+ if a.dry_run:
227
+ print(readme)
228
+ return
229
+
230
+ api = HfApi(token=tok)
231
+ create_repo(repo_id=a.repo, repo_type="model", private=False, exist_ok=True, token=tok)
232
+ up = [(f, os.path.join(src, f)) for f in sorted(os.listdir(src))
233
+ if os.path.isfile(os.path.join(src, f))]
234
+ remote = {}
235
+ try:
236
+ for ent in api.model_info(a.repo, files_metadata=True).siblings or []:
237
+ lfs = getattr(ent, "lfs", None)
238
+ raw = (lfs.get("sha256") or lfs.get("oid")) if isinstance(lfs, dict) else None
239
+ remote[ent.rfilename] = raw if isinstance(raw, str) and len(raw) == 64 else None
240
+ except Exception as e:
241
+ print("(no listing:", type(e).__name__, ")", flush=True)
242
+ todo = [f for f, p in up if remote.get(f) != sha(p)]
243
+ print(f"uploading {len(todo)} of {len(up)} files to {a.repo}", flush=True)
244
+ if todo:
245
+ for i in range(0, len(todo), 120): # one commit per 120 files: E-033's rate ceiling
246
+ b = todo[i:i + 120]
247
+ api.upload_folder(repo_id=a.repo, folder_path=src, path_in_repo="",
248
+ allow_patterns=b, max_workers=2, repo_type="model",
249
+ commit_message=f"publish weights: batch {i // 120 + 1} of {len(b)} file(s)")
250
+ back = set(api.list_repo_files(repo_id=a.repo, repo_type="model"))
251
+ missing = sorted({f for f, _ in up} - back)
252
+ R = {"repo": a.repo, "params": n_p, "tensors": n_t, "files": len(back), "missing": missing}
253
+ for f in ("model.safetensors", "config.json"):
254
+ try:
255
+ d = urllib.request.urlopen(f"https://huggingface.co/{a.repo}/resolve/main/{f}",
256
+ timeout=600).read()
257
+ R[f"anon_{f}"] = {"bytes": len(d), "sha_ok": hashlib.sha256(d).hexdigest() == sha(os.path.join(src, f))}
258
+ except Exception as e:
259
+ R[f"anon_{f}"] = {"error": f"{type(e).__name__}: {str(e)[:120]}"}
260
+ R["published_verified"] = bool(not missing and all(R.get(f"anon_{x}", {}).get("sha_ok")
261
+ for x in ("model.safetensors", "config.json")))
262
+ print("PUBLISH_JSON_BEGIN")
263
+ print(json.dumps(R, indent=1, default=str))
264
+ print("PUBLISH_JSON_END")
265
+ print("NEXT (Gate 5, on a fresh instance with an empty cache): "
266
+ f"python build/publish_model.py --repo {a.repo} --verify-only", flush=True)
267
+ raise SystemExit(0 if R["published_verified"] else 11)
268
+
269
+
270
+ if __name__ == "__main__":
271
+ main()