Spaces:
Running
Running
File size: 11,023 Bytes
99eecc5 249a0e9 0cea977 99eecc5 0cea977 249a0e9 99eecc5 5624f47 99eecc5 249a0e9 0cea977 249a0e9 99eecc5 0cea977 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 0cea977 e21d11c 249a0e9 0cea977 99eecc5 249a0e9 99eecc5 0cea977 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 249a0e9 99eecc5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | """Prepare a reviewable, prebuilt static Space. Never uploads or creates a repo."""
import argparse
import hashlib
import html
import json
import re
import shutil
from pathlib import Path
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--origin', required=True, help='Actual HTTPS app origin returned by HF, or the private preview origin')
parser.add_argument('--site', help='Optional X handle, e.g. @yourhandle; no account is assumed')
parser.add_argument('--output', default='artifacts/hf-space-preview')
args = parser.parse_args()
url = urlsplit(args.origin)
if url.scheme != 'https' or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in ('', '/'):
parser.error('--origin must be an HTTPS origin with no credentials, path, query or fragment')
if args.site and not re.fullmatch(r'@[A-Za-z0-9_]{1,15}', args.site):
parser.error('--site must be an X handle')
origin = args.origin.rstrip('/')
out = (ROOT / args.output).resolve()
if not out.is_relative_to(ROOT / 'artifacts') or out == ROOT / 'artifacts':
parser.error('output must be a dedicated subdirectory of artifacts/')
if out.exists():
parser.error('output already exists; choose a fresh directory to preserve the previous export')
required = ['dist/index.html', 'dist/player.html', 'public/card.png', 'package-lock.json', 'LICENSE', 'CORPUS-LICENSE.txt']
for name in required:
if not (ROOT / name).is_file():
parser.error(f'missing {name}; build and create the real preview image first')
out.mkdir(parents=True)
shutil.copytree(ROOT / 'dist', out / 'dist')
shutil.copy2(ROOT / 'public/card.png', out / 'dist/card.png')
shutil.copytree(ROOT / 'src', out / 'src')
for name in ['package.json', 'package-lock.json', 'vite.config.js', 'index.html', 'player.html', 'LICENSE', 'CORPUS-LICENSE.txt']:
shutil.copy2(ROOT / name, out / name)
(out / 'scripts').mkdir()
shutil.copy2(ROOT / 'scripts/export-space.py', out / 'scripts/export-space.py')
# Preserve exact source-form public assets needed to rebuild without a second copy
# of the 61 MB runtime. This manifest also identifies all runtime dependencies.
public_paths = [p.relative_to(ROOT / 'public').as_posix() for p in (ROOT / 'public').rglob('*') if p.is_file()]
(out / 'public-files.json').write_text(json.dumps(sorted(public_paths), indent=2)+'\n')
(out / 'restore-public.py').write_text('''from pathlib import Path
import json, shutil
root=Path(__file__).resolve().parent
for name in json.loads((root/'public-files.json').read_text()):
dest=root/'public'/name
dest.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(root/'dist'/name,dest)
print('Restored public/ from the pinned static export.')
''')
escape=lambda value:html.escape(value, quote=True)
site=f'<meta name="twitter:site" content="{escape(args.site)}">' if args.site else ''
share=f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Train a tiny transformer from scratch on X</title>
<meta name="twitter:card" content="player">{site}
<meta name="twitter:title" content="Train a tiny transformer from scratch on X">
<meta name="twitter:description" content="Train a tiny transformer from scratch in your browser. Real loss and live text. Click to start.">
<meta name="twitter:player" content="{escape(origin)}/player.html">
<meta name="twitter:player:width" content="640"><meta name="twitter:player:height" content="600">
<meta name="twitter:image" content="{escape(origin)}/card.png">
<meta name="twitter:image:alt" content="Actual training run: random characters at first, a short story after 350 steps. Results and timing depend on your device.">
<meta property="og:type" content="website"><meta property="og:title" content="Train a tiny transformer from scratch on X">
<meta property="og:description" content="A tiny model learns to write from scratch on your device.">
<meta property="og:image" content="{escape(origin)}/card.png"><meta property="og:url" content="{escape(origin)}/share.html">
<style>body{{font:18px/1.6 system-ui;background:#f6f5ef;color:#2c4337;max-width:720px;margin:40px auto;padding:20px}}img{{width:100%;height:auto;border-radius:12px}}a{{color:#32664d}}small{{font-size:13px}}</style></head>
<body><h1>Train a tiny transformer from scratch on X.</h1><p>Train a tiny transformer from scratch on your device.</p>
<a href="./player.html"><img src="./card.png" width="640" height="480" alt="Actual output, before and after training"></a>
<p><a href="./player.html">Start the experiment ↗</a> · <a href="./">Open the full lab ↗</a></p>
<small>This image shows a real training run. The model starts with random weights when you press Train. Timing depends on your device.</small></body></html>'''
(out / 'dist/share.html').write_text(share)
(out / 'dist/robots.txt').write_text('User-agent: *\nAllow: /\n')
(out / 'README.md').write_text('''---
title: First words
emoji: 🌱
colorFrom: green
colorTo: yellow
sdk: static
app_file: dist/index.html
fullWidth: true
disable_embedding: false
license: mit
---
# First words
Train a real character-level causal transformer from random weights in a browser
Worker. This static Space only serves files. All training runs on the visitor’s
device: no HF training server, API key, pretrained checkpoint or paid GPU.
jax-js is pinned to 0.1.26. WebGPU is preferred, with WASM/CPU as fallback.
Context: Thomas requested this prototype, inspired by Pieter Levels’ Windows XP
experience embedded in a tweet. The standalone lab came first, followed by the
compact Player Card. The lab also offers original NanoGPT/PyTorch and a modded
research variant adapted to CPU. These are not the CUDA speed-record code.
## Try it
- `/player.html`: compact card, explicit start, pause/resume, reset, live loss and
training-stage generations. Open **Dataset & settings** to read the exact training
text, inspect the model and edit hyperparameters. Expand shows the Hugging Face Space address with a copy button. Paste it into a new browser tab; weights are not transferred. X sandboxed popups are incompatible with the Hugging Face COOP headers.
- `/`: full lab, including custom text and Python backends.
- `/share.html?v=4`: English sharing page with Player Card metadata in its initial HTML.
The card offers two datasets: short synthetic stories (CC0) and a repeating ABC
pattern. Two models are available: Tiny (1 layer, width 32) and Small (2 layers,
width 64), both with 4 attention heads. For stories they have 22,240 and 118,336
parameters respectively. Counts depend on the vocabulary and context. The UI
shows exact parameter counts and FP32 weight size in KiB (1 KiB = 1,024 bytes).
This excludes gradients, optimizer state, temporary buffers and the runtime.
The compact header reads “Train a tiny transformer from scratch on X” and shows
the actual compute backend once loaded. Generation panels are labeled “Start of
training” and “Current stage of training”. The dropdowns for model and dataset
remain visible.
In **Dataset & settings**, learning rate, training steps, context, batch size,
sampling interval, temperature, sample length and learning-rate schedule can be
edited before training. The optimizer constants and seeds are also listed.
Inputs are validated before a Worker starts and are locked during training.
Changing dataset restores its defaults; reset preserves the current settings.
Below the training samples, **Try your model** accepts a new prompt once training
finishes. Generate runs inference with the trained weights, the configured sampling
temperature and length, and a new seed. Stop cancels generation. Its separate output
never replaces either training sample or changes the training loss, elapsed time,
optimizer or weights. Unsupported characters are reported without losing the model.
On shorter cards, the inference output sits beside its prompt to preserve space;
very short legacy cards can scroll inside the results area while the footer stays
visible. The dataset/settings panel scrolls independently.
Stories use 350 steps, context 256, batch 4, AdamW, cooldown and temperature 0.4.
ABC uses 100 steps and context 32. The loss plot labels training steps and
cross-entropy in nats per character; the line and displayed loss use a rolling
20-step mean. The stories are deliberately repetitive and can be memorized;
a low training loss does not establish general language ability.
Weights exist only in memory. Reset or closing the page clears them. Cards pause
when offscreen. A new sampling seed can still produce the same text. Generated
output is never corrected after sampling.
## Build locally
This export is prebuilt; no HF build or compute is needed. Tested with Node
24.18.1 and npm 11.16.0:
```sh
python3 restore-public.py
npm ci
npm run build
```
Regenerate sharing metadata for the actual destination origin:
```sh
python3 scripts/export-space.py --origin https://YOUR-APP-HOST
```
The export contains application sources and runtime assets. The full test harness
and private development notes are kept in the development workspace.
## Validation and limits
Forward/loss/gradients have been checked against finite differences and the
NanoGPT transcription against PyTorch. Parameter counts are checked against
actual model tensors for both sizes and both datasets. Browser tests cover real
training and generation, pause/resume, reset, dataset preview and responsive
layouts. English UI messages include the full lab and both Python Workers.
The historical benchmark archive remains in French and is labeled accordingly.
The public player has trained inside a local reproduction of X’s observed two
iframe sandbox layers. The official X validator reported `Card loaded successfully`;
Thomas subsequently confirmed rendering in X with a screenshot. These observations
do not establish compatibility with every X client or mobile device. The card
requests a 640 × 600 aspect ratio and also fits older 4:3 cards. X controls its
metadata cache. No tweet has been posted by this agent.
The Python/PyTorch runtime (~61 MB) downloads only when selected in the full lab.
Software WebGPU has been tested; no physical GPU or mobile speed is promised.
Component licenses are retained alongside their files. The original upstream
NanoGPT model.py and the 32 pinned runtime assets are unchanged.
''')
manifest={'origin':origin,'site':args.site,'published':False,'files':[]}
for path in sorted(out.rglob('*')):
if path.is_file():manifest['files'].append({'path':path.relative_to(out).as_posix(),'bytes':path.stat().st_size,'sha256':hashlib.sha256(path.read_bytes()).hexdigest()})
(out / 'export-manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(json.dumps({'output':str(out),'files':len(manifest['files']),'bytes':sum(f['bytes'] for f in manifest['files']),'origin':origin,'published':False},indent=2))
|