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))