publish_model: anonymous clean room, every-file read-back, list-only-on-404 resume, card fields read from run_summary (review E-045)
Browse files- build/publish_model.py +78 -22
build/publish_model.py
CHANGED
|
@@ -74,6 +74,30 @@ def card(cfg, man, aud, run, repo, files, n_params):
|
|
| 74 |
cf = man.get('contamination_filter') or {}
|
| 75 |
tok_consumed = int(run.get("tokens_consumed") or 0)
|
| 76 |
loss = " -> ".join(str(x) for x in (run.get("prior_loss"), run.get("final_loss")) if x is not None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
rows = "\n".join(f"| `{k}` | {v['sha256'][:16]}… | {v['bytes']:,} |" for k, v in sorted(files.items()))
|
| 78 |
return f"""---
|
| 79 |
license: cc-by-sa-4.0
|
|
@@ -98,17 +122,18 @@ report it honestly, including where that is near chance.
|
|
| 98 |
|---|---|
|
| 99 |
| Parameters | **{n_params:,}** (recomputed from `model.safetensors`'s own header, not asserted) |
|
| 100 |
| Architecture | {p[0]} layers, hidden {p[1]}, GQA {cfg["num_attention_heads"]} Q / {cfg["num_key_value_heads"]} KV heads, SwiGLU {cfg["intermediate_size"]}, RMSNorm {cfg["rms_norm_eps"]}, RoPE θ={rope}, **tied** embeddings, vocab {p[2]:,} |
|
| 101 |
-
| Sequence length | {run
|
| 102 |
| Tokens consumed | **{tok_consumed:,}** ({100.0 * tok_consumed / 1e9:.2f} % of the 1.0 B target) |
|
| 103 |
-
| Batch | micro {run
|
| 104 |
-
| Optimiser |
|
| 105 |
-
| Precision | fp16 autocast + fp32 master weights + GradScaler
|
| 106 |
-
| Throughput | {run
|
| 107 |
-
| Attention | `{run.get('effective_attn'
|
| 108 |
-
| Gradient checkpointing | {run
|
|
|
|
| 109 |
| Wall clock | {run.get('session_seconds', run.get('seconds', 0)) / 3600.0:.2f} h in the session that produced this export. The run is split across sessions at checkpoint boundaries, so this is **not** the total training time |
|
| 110 |
| Loss | {loss} |
|
| 111 |
-
| Validation PPL | {
|
| 112 |
|
| 113 |
## Data
|
| 114 |
|
|
@@ -185,8 +210,13 @@ def main():
|
|
| 185 |
dest = "/tmp/cleanroom_" + os.path.basename(a.repo)
|
| 186 |
shutil.rmtree(dest, ignore_errors=True)
|
| 187 |
shutil.rmtree("/tmp/cleanroom_hf", ignore_errors=True)
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
import json as _j
|
| 191 |
cfg = _j.load(open(os.path.join(dest, "config.json")))
|
| 192 |
n_t, n_p, dt = safetensors_counts(os.path.join(dest, "model.safetensors"))
|
|
@@ -247,25 +277,44 @@ def main():
|
|
| 247 |
raise SystemExit("no run_summary.json next to the export: the card would be full of blanks. "
|
| 248 |
"Pass --from-dir at the trainer's final/ export, or --dry-run to preview.")
|
| 249 |
readme = card(cfg, man, aud, dict(run, params_total=n_p), a.repo, files, n_p)
|
| 250 |
-
open(os.path.join(src, "README.md"), "w", encoding="utf-8").write(readme)
|
| 251 |
print("card:", f"{n_p:,} params from the safetensors header; {len(files)} files hashed", flush=True)
|
| 252 |
if a.dry_run:
|
|
|
|
|
|
|
| 253 |
print(readme.encode("utf-8", "replace").decode("utf-8", "replace"))
|
| 254 |
return
|
|
|
|
| 255 |
|
| 256 |
api = HfApi(token=tok)
|
| 257 |
create_repo(repo_id=a.repo, repo_type="model", private=False, exist_ok=True, token=tok)
|
| 258 |
up = [(f, os.path.join(src, f)) for f in sorted(os.listdir(src))
|
| 259 |
if os.path.isfile(os.path.join(src, f))]
|
|
|
|
| 260 |
remote = {}
|
| 261 |
try:
|
| 262 |
-
for
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
except Exception as e:
|
| 267 |
-
|
| 268 |
-
|
|
|
|
| 269 |
print(f"uploading {len(todo)} of {len(up)} files to {a.repo}", flush=True)
|
| 270 |
if todo:
|
| 271 |
for i in range(0, len(todo), 120): # one commit per 120 files: E-033's rate ceiling
|
|
@@ -276,7 +325,11 @@ def main():
|
|
| 276 |
back = set(api.list_repo_files(repo_id=a.repo, repo_type="model"))
|
| 277 |
missing = sorted({f for f, _ in up} - back)
|
| 278 |
R = {"repo": a.repo, "params": n_p, "tensors": n_t, "files": len(back), "missing": missing}
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
try:
|
| 281 |
h, nbytes = hashlib.sha256(), 0 # stream it: 1.7 GB does not belong in RAM
|
| 282 |
with urllib.request.urlopen(f"https://huggingface.co/{a.repo}/resolve/main/{f}",
|
|
@@ -284,11 +337,14 @@ def main():
|
|
| 284 |
for chunk in iter(lambda: r.read(1 << 20), b""):
|
| 285 |
nbytes += len(chunk)
|
| 286 |
h.update(chunk)
|
| 287 |
-
|
| 288 |
except Exception as e:
|
| 289 |
-
|
| 290 |
-
R["
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
| 292 |
print("PUBLISH_JSON_BEGIN")
|
| 293 |
print(json.dumps(R, indent=1, default=str))
|
| 294 |
print("PUBLISH_JSON_END")
|
|
|
|
| 74 |
cf = man.get('contamination_filter') or {}
|
| 75 |
tok_consumed = int(run.get("tokens_consumed") or 0)
|
| 76 |
loss = " -> ".join(str(x) for x in (run.get("prior_loss"), run.get("final_loss")) if x is not None)
|
| 77 |
+
# Same rule as the architecture: anything the card states about *this* model comes from its own
|
| 78 |
+
# `run_summary.json`, and a field that is not there stops the publish rather than rendering as None.
|
| 79 |
+
# The Optimiser/Precision/Batch rows used to be typed prose, which is how a card ends up describing the
|
| 80 |
+
# recipe somebody meant to run (review E-045/2).
|
| 81 |
+
need = ("optim", "lr", "warmup_frac", "decay_frac", "world", "tokens_per_step", "seq_len",
|
| 82 |
+
"micro_batch", "accum", "attn", "grad_ckpt", "tok_per_s", "seed", "data_seed")
|
| 83 |
+
absent = [k for k in need if run.get(k) is None]
|
| 84 |
+
if absent:
|
| 85 |
+
raise SystemExit(f"run_summary.json carries no {absent}, so the card cannot state them without "
|
| 86 |
+
"guessing. Publish the summary the trainer wrote, or say in the card what is "
|
| 87 |
+
"unknown -- never both.")
|
| 88 |
+
geom = run["micro_batch"] * run["accum"] * run["world"] * run["seq_len"]
|
| 89 |
+
if geom != run["tokens_per_step"]:
|
| 90 |
+
raise SystemExit(f"the batch row would be a false statement: {run['micro_batch']} x "
|
| 91 |
+
f"{run['accum']} x {run['world']} x {run['seq_len']} = {geom} != "
|
| 92 |
+
f"tokens_per_step {run['tokens_per_step']}")
|
| 93 |
+
if run.get("val_ppl") is None and run.get("val_skipped") and run.get("val_loss_error") is None:
|
| 94 |
+
val_row = ("not measured on this leg -- a session that stops at a checkpoint boundary skips the "
|
| 95 |
+
"validation pass by design; the run's PPL comes from the horizon export")
|
| 96 |
+
elif run.get("val_ppl") is None:
|
| 97 |
+
val_row = f"NOT MEASURED ({run.get('val_loss_error')})"
|
| 98 |
+
else:
|
| 99 |
+
val_row = (f"**{run['val_ppl']:.2f}** on {MIX}'s held-out `val/` shards "
|
| 100 |
+
f"({man['val_tokens']:,} tokens, never trained on)")
|
| 101 |
rows = "\n".join(f"| `{k}` | {v['sha256'][:16]}… | {v['bytes']:,} |" for k, v in sorted(files.items()))
|
| 102 |
return f"""---
|
| 103 |
license: cc-by-sa-4.0
|
|
|
|
| 122 |
|---|---|
|
| 123 |
| Parameters | **{n_params:,}** (recomputed from `model.safetensors`'s own header, not asserted) |
|
| 124 |
| Architecture | {p[0]} layers, hidden {p[1]}, GQA {cfg["num_attention_heads"]} Q / {cfg["num_key_value_heads"]} KV heads, SwiGLU {cfg["intermediate_size"]}, RMSNorm {cfg["rms_norm_eps"]}, RoPE θ={rope}, **tied** embeddings, vocab {p[2]:,} |
|
| 125 |
+
| Sequence length | {run['seq_len']} — which is why ARC's 25-shot prompts are truncated from the left by the eval harness |
|
| 126 |
| Tokens consumed | **{tok_consumed:,}** ({100.0 * tok_consumed / 1e9:.2f} % of the 1.0 B target) |
|
| 127 |
+
| Batch | micro {run['micro_batch']} × accum {run['accum']} × {run['world']} ranks = {run['tokens_per_step']:,} tokens/step |
|
| 128 |
+
| Optimiser | `{run['optim']}`, LR {run['lr']}, warmup fraction {run['warmup_frac']}, trapezoid decay over the final {100.0 * (1.0 - (run['decay_frac'] or 0)):.1f} % of the schedule |
|
| 129 |
+
| Precision | fp16 autocast + fp32 master weights + GradScaler — asserted at the start of every session, and each session's log line `precision: fp16=True bf16=False scaler=GradScaler` is the evidence |
|
| 130 |
+
| Throughput | {run['tok_per_s']:,} tok/s in the session that produced this export |
|
| 131 |
+
| Attention | `{run.get('effective_attn') or run['attn']}` |
|
| 132 |
+
| Gradient checkpointing | `{run['grad_ckpt']}` |
|
| 133 |
+
| Randomness | model seed {run['seed']}, data seed {run['data_seed']} |
|
| 134 |
| Wall clock | {run.get('session_seconds', run.get('seconds', 0)) / 3600.0:.2f} h in the session that produced this export. The run is split across sessions at checkpoint boundaries, so this is **not** the total training time |
|
| 135 |
| Loss | {loss} |
|
| 136 |
+
| Validation PPL | {val_row} |
|
| 137 |
|
| 138 |
## Data
|
| 139 |
|
|
|
|
| 210 |
dest = "/tmp/cleanroom_" + os.path.basename(a.repo)
|
| 211 |
shutil.rmtree(dest, ignore_errors=True)
|
| 212 |
shutil.rmtree("/tmp/cleanroom_hf", ignore_errors=True)
|
| 213 |
+
# Gate 5's whole claim is "a stranger can load this". `install()` above puts HF_TOKEN in the
|
| 214 |
+
# environment and `huggingface_hub` picks it up from there, so without this the clean room would
|
| 215 |
+
# be authenticated and a private or gated repo would pass (review E-045/1).
|
| 216 |
+
for k in ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGING_FACE_TOKEN"):
|
| 217 |
+
os.environ.pop(k, None)
|
| 218 |
+
got = snapshot_download(a.repo, local_dir=dest, token=False)
|
| 219 |
+
print("clean-room download (anonymous) ->", got, flush=True)
|
| 220 |
import json as _j
|
| 221 |
cfg = _j.load(open(os.path.join(dest, "config.json")))
|
| 222 |
n_t, n_p, dt = safetensors_counts(os.path.join(dest, "model.safetensors"))
|
|
|
|
| 277 |
raise SystemExit("no run_summary.json next to the export: the card would be full of blanks. "
|
| 278 |
"Pass --from-dir at the trainer's final/ export, or --dry-run to preview.")
|
| 279 |
readme = card(cfg, man, aud, dict(run, params_total=n_p), a.repo, files, n_p)
|
|
|
|
| 280 |
print("card:", f"{n_p:,} params from the safetensors header; {len(files)} files hashed", flush=True)
|
| 281 |
if a.dry_run:
|
| 282 |
+
# A preview that writes into the export is not a preview: it leaves a README behind that the next
|
| 283 |
+
# real run then treats as one of the files to publish (review E-045, minor).
|
| 284 |
print(readme.encode("utf-8", "replace").decode("utf-8", "replace"))
|
| 285 |
return
|
| 286 |
+
open(os.path.join(src, "README.md"), "w", encoding="utf-8").write(readme)
|
| 287 |
|
| 288 |
api = HfApi(token=tok)
|
| 289 |
create_repo(repo_id=a.repo, repo_type="model", private=False, exist_ok=True, token=tok)
|
| 290 |
up = [(f, os.path.join(src, f)) for f in sorted(os.listdir(src))
|
| 291 |
if os.path.isfile(os.path.join(src, f))]
|
| 292 |
+
local_sha = {f: sha(p) for f, p in up}
|
| 293 |
remote = {}
|
| 294 |
try:
|
| 295 |
+
for attempt in (1, 2, 3):
|
| 296 |
+
try:
|
| 297 |
+
for ent in api.model_info(a.repo, files_metadata=True).siblings or []:
|
| 298 |
+
lfs = getattr(ent, "lfs", None)
|
| 299 |
+
raw = (lfs.get("sha256") or lfs.get("oid")) if isinstance(lfs, dict) else None
|
| 300 |
+
remote[ent.rfilename] = raw if isinstance(raw, str) and len(raw) == 64 else None
|
| 301 |
+
break
|
| 302 |
+
except Exception as e:
|
| 303 |
+
# E-034 again: "this repo has nothing in it yet" and "the Hub did not answer" are different
|
| 304 |
+
# facts. The first means upload everything; the second, swallowed into `remote = {}` here,
|
| 305 |
+
# means silently re-uploading 1.3 GB of bytes that are already on the Hub.
|
| 306 |
+
if type(e).__name__ in ("RepositoryNotFoundError", "GatedRepoError"):
|
| 307 |
+
print("(remote listing empty -- first publish)", flush=True)
|
| 308 |
+
break
|
| 309 |
+
if attempt == 3:
|
| 310 |
+
raise
|
| 311 |
+
print(f"(listing failed {type(e).__name__}, retry {attempt}/3)", flush=True)
|
| 312 |
+
import time as _t
|
| 313 |
+
_t.sleep(10 * attempt)
|
| 314 |
except Exception as e:
|
| 315 |
+
raise SystemExit(f"cannot read the remote listing after 3 tries ({type(e).__name__}: "
|
| 316 |
+
f"{str(e)[:160]}) -- refusing to guess what is already uploaded")
|
| 317 |
+
todo = [f for f, _ in up if remote.get(f) != local_sha[f]]
|
| 318 |
print(f"uploading {len(todo)} of {len(up)} files to {a.repo}", flush=True)
|
| 319 |
if todo:
|
| 320 |
for i in range(0, len(todo), 120): # one commit per 120 files: E-033's rate ceiling
|
|
|
|
| 325 |
back = set(api.list_repo_files(repo_id=a.repo, repo_type="model"))
|
| 326 |
missing = sorted({f for f, _ in up} - back)
|
| 327 |
R = {"repo": a.repo, "params": n_p, "tensors": n_t, "files": len(back), "missing": missing}
|
| 328 |
+
# Every file, not just the weights and the config: `list_repo_files` proves a name exists, and a
|
| 329 |
+
# tokenizer whose bytes did not land loads as a *different* tokenizer, which is the failure that
|
| 330 |
+
# silently invalidates every number in Phase 6 (E-031's rule, applied to the model repo).
|
| 331 |
+
anon = {}
|
| 332 |
+
for f, _ in up:
|
| 333 |
try:
|
| 334 |
h, nbytes = hashlib.sha256(), 0 # stream it: 1.7 GB does not belong in RAM
|
| 335 |
with urllib.request.urlopen(f"https://huggingface.co/{a.repo}/resolve/main/{f}",
|
|
|
|
| 337 |
for chunk in iter(lambda: r.read(1 << 20), b""):
|
| 338 |
nbytes += len(chunk)
|
| 339 |
h.update(chunk)
|
| 340 |
+
anon[f] = {"bytes": nbytes, "sha_ok": h.hexdigest() == local_sha[f]}
|
| 341 |
except Exception as e:
|
| 342 |
+
anon[f] = {"error": f"{type(e).__name__}: {str(e)[:120]}"}
|
| 343 |
+
R["anon_readback"] = {k: (v.get("sha_ok") if "sha_ok" in v else v.get("error")) for k, v in anon.items()}
|
| 344 |
+
bad = sorted(k for k, v in anon.items() if not v.get("sha_ok"))
|
| 345 |
+
R["published_verified"] = bool(not missing and not bad)
|
| 346 |
+
if bad:
|
| 347 |
+
R["anon_failed"] = bad[:10]
|
| 348 |
print("PUBLISH_JSON_BEGIN")
|
| 349 |
print(json.dumps(R, indent=1, default=str))
|
| 350 |
print("PUBLISH_JSON_END")
|