Small Language Model — complete code
The full training, export and browser source. Choose a file below. To start training, save just train.py. It includes the configuration, model, data pipeline and training loop. Setup commands are in README.md. Code is shown here, not executed.
Koulutuksen, viennin ja selainversion koko lähdekoodi. Valitse tiedosto alta. Koulutuksen aloittamiseen riittää train.py. Se sisältää asetukset, mallin, dataputken ja koulutussilmukan. Asennuskomennot ovat README.md-tiedostossa. Koodi näytetään tällä sivulla, sitä ei suoriteta.
README.md
Raw# Small Language Model — complete source
To start training, save just training/train.py as train.py. It contains the
configuration, Transformer, data pipeline and training loop. No project modules
or configuration files need to be copied alongside it.
Install dependencies in your Python environment, then run:
python -m pip install tensorflow==2.21.0 keras==3.15.1 datasets==5.1.0
python train.py
For NVIDIA GPU dependencies on Linux, use tensorflow[and-cuda]==2.21.0 in
place of the plain TensorFlow package (quote the package name in your shell). GPU use is automatic when TensorFlow
sees a compatible GPU; otherwise it uses CPU. Training requires substantial
RAM and compute. The corpus streams into a RAM cache, not dataset files;
small Hub metadata can still be cached.
The default is the 19.4M model, saved under runs/small. To train the larger
model instead, or change the update target:
python train.py --large
python train.py --steps 1000 --run runs/short-experiment
The larger model uses runs/large by default. Choose a new run directory when
changing model dimensions.
A run directory contains configuration, bounded checkpoints, loss.csv and
optimizer state. Repeating a command resumes that run to its configured target and skips training
if the target is already reached. Targets need not be multiples of500.
The recipe sets a 20,000-update target. The published small model was trained
across resumed sessions to step 20,422; the published larger model is the
completed checkpoint at step 20,000. A fresh run is not a byte-identical reproduction.
Only for browser export, also save training/export.py beside train.py and
create the browser directory using the browser files shown in the viewer.
The commands below assume train.py and export.py are in a training directory,
with browser as a sibling directory. Run from the training directory:
python export.py runs/small ../browser/model --quantize
python export.py runs/large ../browser/model/large --quantize
The exporter writes the model manifest, uint8 weights, deterministic gzip
sibling and reference logits. The browser expands weights back to float32.
The page offers both models; export both before choosing the larger option.
Download sizes printed on the page describe our published models, not every
fresh training run. To use only the small model, leave the default selected.
From the browser directory:
python fetch_runtime.py
python -m http.server 8000 --bind 127.0.0.1
Open http://localhost:8000. The Python HTTP server serves static files only;
inference runs in the browser. Use HTTP locally, or HTTPS when hosting remotely.
For HTTP gzip downloads, configure your static server to serve each .bin.gz
sibling when Accept-Encoding includes gzip. The simple local server sends the
uncompressed uint8 binary; the model and computation are the same.
The browser page is the demo source before the site's optional Analytics
fragment is inserted. These files do not include an Analytics tag. No API key,
server inference, VM management or deployment scripts are needed.
TensorFlow.js is fetched from the pinned URLs in vendor.json with SHA-256
verification. Its Apache 2.0 license is downloaded as vendor/LICENSE. The
SimpleStories dataset revision is pinned in train.py; see the article for
its dataset and paper links.
training/train.py
Raw"""Train the article's small language model: python train.py [--large]."""
import argparse
from dataclasses import asdict, dataclass
import hashlib
import json
from pathlib import Path
import tensorflow as tf
from datasets import load_dataset
REVISION = "e63b8adc3b1a1bdc7cac5b500d150b71346b0628"
@dataclass(frozen=True)
class Config:
context: int = 512
width: int = 512
layers: int = 6
heads: int = 8
batch: int = 32
steps: int = 20000
learning_rate: float = 0.0003
weight_decay: float = 0.01
seed: int = 42
report_every: int = 500
eval_batches: int = 8
sample_bytes: int = 160
temperature: float = 0.8
top_k: int = 40
prompts: tuple = ("The ", "Once upon a time", "I think")
def __post_init__(self):
for key in ("context", "width", "layers", "heads", "batch", "steps",
"report_every", "eval_batches", "sample_bytes"):
value = getattr(self, key)
if type(value) is not int or value < 1:
raise ValueError(f"{key} must be a positive integer")
if self.width % self.heads:
raise ValueError("width must be divisible by heads")
if not 0 <= self.seed < 2**31:
raise ValueError("seed must be in [0, 2**31)")
if not 0 < self.learning_rate <= 1 or not 0 <= self.weight_decay <= 1:
raise ValueError("invalid learning rate or weight decay")
if not 0 < self.temperature <= 10 or not 1 <= self.top_k <= 256:
raise ValueError("invalid sampling settings")
if not self.prompts or any(not isinstance(p, str) or not p for p in self.prompts):
raise ValueError("prompts must contain nonempty strings")
def parameters(self):
# Separate input/output embeddings; learned positions; attention biases;
# FFN expansion 4x; two pre-norms per block and one final norm.
d = self.width
return 512*d + self.context*d + 256 + 2*d + self.layers*(12*d*d + 13*d)
def as_dict(self):
return asdict(self)
class Block(tf.keras.layers.Layer):
def __init__(self, config, **kwargs):
super().__init__(**kwargs)
self.norm1 = tf.keras.layers.LayerNormalization(epsilon=1e-5)
self.attention = tf.keras.layers.MultiHeadAttention(
num_heads=config.heads, key_dim=config.width // config.heads,
dropout=0.0, flash_attention=False)
self.norm2 = tf.keras.layers.LayerNormalization(epsilon=1e-5)
self.expand = tf.keras.layers.Dense(4 * config.width, activation=tf.nn.gelu)
self.contract = tf.keras.layers.Dense(config.width)
def call(self, x, training=False):
normalized = self.norm1(x)
x = x + self.attention(normalized, normalized,
use_causal_mask=True, training=training)
return x + self.contract(self.expand(self.norm2(x)))
class LanguageModel(tf.keras.Model):
def __init__(self, config):
super().__init__()
self.context = config.context
self.tokens = tf.keras.layers.Embedding(256, config.width)
self.positions = tf.keras.layers.Embedding(config.context, config.width)
# Explicit named attributes also make every block visible to TF checkpoints.
self.block_names = tuple(f"block_{i}" for i in range(config.layers))
for name in self.block_names:
setattr(self, name, Block(config, name=name))
self.norm = tf.keras.layers.LayerNormalization(epsilon=1e-5)
self.output_head = tf.keras.layers.Dense(256, dtype="float32")
def call(self, ids, training=False):
length = tf.shape(ids)[1]
tf.debugging.assert_less_equal(length, self.context)
x = self.tokens(ids) + self.positions(tf.range(length))[None, :, :]
for name in self.block_names:
x = getattr(self, name)(x, training=training)
return self.output_head(self.norm(x))
@tf.function(input_signature=[tf.TensorSpec((1, None), tf.int32)])
def next_logits(self, ids):
"""Reuse one traced inference graph while the prompt length changes."""
return self(ids, training=False)[:, -1, :]
def make_model(config):
tf.keras.utils.set_random_seed(config.seed)
model = LanguageModel(config)
model(tf.zeros((1, config.context), dtype=tf.int32))
if model.count_params() != config.parameters():
raise ValueError("Model differs from parameter accounting")
return model
def make_optimizer(config, model):
optimizer = tf.keras.optimizers.AdamW(
learning_rate=config.learning_rate, weight_decay=config.weight_decay,
global_clipnorm=1.0)
optimizer.exclude_from_weight_decay(var_names=["bias", "beta", "gamma"])
optimizer.build(model.trainable_variables)
return optimizer
def dataset(stories, context, batch_size, training=False):
texts = tf.data.Dataset.from_generator(
lambda: (row["story"] for row in stories),
output_signature=tf.TensorSpec((), tf.string),
).cache() # RAM, not disk.
data = texts.map(
lambda text: tf.io.decode_raw(text + "\n\n", tf.uint8),
num_parallel_calls=tf.data.AUTOTUNE,
)
data = data.unbatch().batch(context + 1, drop_remainder=True)
if training:
data = data.shuffle(4096, seed=42)
data = data.batch(batch_size, drop_remainder=True)
data = data.map(lambda chunk: (chunk[:, :-1], chunk[:, 1:]))
return data.prefetch(tf.data.AUTOTUNE)
def sample(model, prompt, length=200):
tokens = list(prompt.encode())
for i in range(length):
context = tf.constant([tokens[-model.context:]], dtype=tf.int32)
logits = model.next_logits(context) / 0.8
token = tf.random.stateless_categorical(logits, 1, seed=[42, i])
tokens.append(int(token[0, 0]))
return bytes(tokens).decode(errors="replace")
class Progress(tf.keras.callbacks.Callback):
def __init__(self, checkpoints, target_step=None):
super().__init__()
self.checkpoints = checkpoints
self.target_step = target_step
def on_train_batch_end(self, batch, logs=None):
if self.target_step is not None and int(self.model.optimizer.iterations) >= self.target_step:
self.model.stop_training = True
def on_epoch_end(self, epoch, logs=None):
step = int(self.model.optimizer.iterations)
self.checkpoints.save(checkpoint_number=step)
print(sample(self.model, "Once upon a time"), flush=True)
def configure_hardware():
gpus = tf.config.list_physical_devices("GPU")
if gpus:
tf.config.set_visible_devices(gpus[0], "GPU")
tf.config.experimental.set_memory_growth(gpus[0], True)
def story_hash(row):
text = " ".join(row["story"].split())
return hashlib.sha256(text.encode()).digest()
def load_data(config):
stories = load_dataset(
"SimpleStories/SimpleStories", split="train", revision=REVISION,
streaming=True, columns=["story"],
)
# Keep the validation split used by the existing checkpoint.
protected = {story_hash(row) for row in stories.take(1100)}
def held_out(row):
key = story_hash(row)
return key not in protected and int.from_bytes(key[:4], "big") % 100 == 0
validation = dataset(stories.filter(held_out).take(1000), config.context, config.batch)
training = dataset(stories.filter(lambda row: not held_out(row)),
config.context, config.batch, training=True)
return training, validation
def build_model(config):
model = make_model(config)
model.compile(
optimizer=make_optimizer(config, model),
loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),
)
return model
def train(model, training, validation, config, run, initial_checkpoint=None,
max_checkpoints=3, extra_callbacks=()):
run.mkdir(parents=True, exist_ok=True)
checkpoint = tf.train.Checkpoint(model=model, optimizer=model.optimizer)
checkpoints = tf.train.CheckpointManager(
checkpoint, str(run / "checkpoints"), max_to_keep=max_checkpoints,
)
saved = checkpoints.latest_checkpoint or initial_checkpoint
if saved:
checkpoint.restore(saved).expect_partial().assert_existing_objects_matched()
if int(model.optimizer.iterations) >= config.steps:
return
(run / "config.json").write_text(json.dumps(
{"model": config.as_dict(), "revision": REVISION,
"initial_checkpoint": initial_checkpoint}, indent=2,
))
try:
return model.fit(
training.repeat(), validation_data=validation,
steps_per_epoch=config.report_every,
epochs=(config.steps + config.report_every - 1) // config.report_every,
initial_epoch=int(model.optimizer.iterations) // config.report_every,
shuffle=False, verbose=2,
callbacks=[
Progress(checkpoints, config.steps),
tf.keras.callbacks.CSVLogger(str(run / "loss.csv"), append=True),
*extra_callbacks,
],
)
finally:
checkpoints.save(checkpoint_number=int(model.optimizer.iterations))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--large", action="store_true", help="Use the 152M model")
parser.add_argument("--steps", type=int, default=20000)
parser.add_argument("--run", type=Path)
args = parser.parse_args()
configure_hardware()
config = (Config(width=1024, layers=12, heads=16,
learning_rate=0.0002, steps=args.steps)
if args.large else Config(steps=args.steps))
run = args.run or Path("runs/large" if args.large else "runs/small")
training, validation = load_data(config)
model = build_model(config)
train(model, training, validation, config, run)
if __name__ == "__main__":
main()
training/export.py
Raw"""Export final inference weights and reference logits; no converter dependency."""
import argparse
import hashlib
import gzip
import shutil
import json
from pathlib import Path
import numpy as np
import tensorflow as tf
from train import Config, make_model
def main():
parser = argparse.ArgumentParser()
parser.add_argument("run", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--quantize", action="store_true", help="Export affine uint8 download weights")
args = parser.parse_args()
tf.config.threading.set_intra_op_parallelism_threads(4)
tf.config.threading.set_inter_op_parallelism_threads(1)
gpus = tf.config.list_physical_devices('GPU')
if gpus:
tf.config.set_visible_devices(gpus[0], 'GPU')
tf.config.experimental.set_memory_growth(gpus[0], True)
config = Config(**json.loads((args.run / "config.json").read_text())["model"])
model = make_model(config)
checkpoint = tf.train.latest_checkpoint(str(args.run / "checkpoints"))
tf.train.Checkpoint(model=model).restore(checkpoint).expect_partial().assert_existing_objects_matched()
args.output.mkdir(parents=True, exist_ok=True)
specs = []
digest = hashlib.sha256()
original_digest = hashlib.sha256()
binary = args.output / ("weights-int8.bin" if args.quantize else "weights.bin")
with binary.open("wb") as stream:
def weight(name, value, shape=None):
array = value.numpy().astype("<f4")
if shape:
array = array.reshape(shape)
original_digest.update(array.tobytes())
spec = {"name": name, "shape": list(array.shape), "dtype": "float32"}
if args.quantize:
minimum = float(array.min())
scale = (float(array.max()) - minimum) / 255 or 1.0
codes = np.clip(np.rint((array - minimum) / scale), 0, 255).astype(np.uint8)
spec["quantization"] = {"dtype":"uint8", "min":minimum, "scale":scale}
# Reference logits must describe the decoded download, not FP32 originals.
value.assign((codes.astype(np.float32) * scale + minimum).reshape(value.shape))
raw = codes.tobytes()
else:
raw = array.tobytes()
stream.write(raw)
digest.update(raw)
specs.append(spec)
def norm(name, layer):
weight(name + ".gamma", layer.gamma)
weight(name + ".beta", layer.beta)
def dense(name, layer, shape=None):
weight(name + ".kernel", layer.kernel, shape)
weight(name + ".bias", layer.bias, (int(np.prod(layer.bias.shape)),))
weight("tokens", model.tokens.embeddings)
weight("positions", model.positions.embeddings)
for i, name in enumerate(model.block_names):
block = getattr(model, name)
prefix = f"b{i}"
norm(prefix + ".norm1", block.norm1)
norm(prefix + ".norm2", block.norm2)
for short, projection in (("q", block.attention.query_dense),
("k", block.attention.key_dense),
("v", block.attention.value_dense),
("o", block.attention.output_dense)):
dense(prefix + "." + short, projection, (config.width, config.width))
dense(prefix + ".expand", block.expand)
dense(prefix + ".contract", block.contract)
norm("norm", model.norm)
dense("head", model.output_head)
if args.quantize:
final = args.output / ("weights-int8-" + digest.hexdigest()[:12] + ".bin")
binary.replace(final)
binary = final
with binary.open('rb') as source, (args.output / (binary.name + '.gz')).open('wb') as target:
with gzip.GzipFile(filename='', mode='wb', compresslevel=6, fileobj=target, mtime=0) as compressed:
shutil.copyfileobj(source, compressed)
manifest_name = 'model-int8.json' if args.quantize else 'model.json'
verification_name = 'verification-int8.json' if args.quantize else 'verification.json'
manifest = {"context": config.context, "width": config.width, "layers": config.layers,
"heads": config.heads, "parameters": model.count_params(),
"checkpoint": Path(checkpoint).name, "weights": specs,
"weight_file": binary.name, "weight_bytes": binary.stat().st_size,
"gzip_bytes": (args.output / (binary.name + ".gz")).stat().st_size,
"verification_file":verification_name, "source_sha256":original_digest.hexdigest(),
"sha256": digest.hexdigest(), "epsilon": 1e-5}
(args.output / manifest_name).write_text(json.dumps(manifest, indent=2) + "\n")
cases = []
for prompt in ("Far far away", "Mira found a key under the old tree.", "A little café"):
ids = list(prompt.encode())
steps = []
for extra in ([], *[[byte] for byte in b" in the"]):
ids.extend(extra)
logits = model(tf.constant([ids], tf.int32), training=False)[0, -1].numpy()
steps.append({"input": extra or list(prompt.encode()), "logits": logits.tolist()})
cases.append({"prompt": prompt, "steps": steps})
ids = list(("A robot found a bright star. " * 20).encode()[:config.context])
cases.append({"prompt": "full context", "steps": [{"input": ids,
"logits": model(tf.constant([ids], tf.int32), training=False)[0, -1].numpy().tolist()}]})
(args.output / verification_name).write_text(json.dumps({"cases": cases}) + "\n")
print(json.dumps({key: manifest[key] for key in ("checkpoint", "weight_bytes", "sha256")}), flush=True)
if __name__ == "__main__":
main()
browser/index.html
Raw<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Small Language Model — Demo · ai.vostman.fi</title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<a class="brand" href="/">ai.vostman.fi</a>
<button id="theme" class="quiet" aria-label="Toggle dark mode">◐</button>
<nav aria-label="Language"><button id="en" aria-pressed="true">EN</button><button id="fi" aria-pressed="false">FI</button></nav>
</header>
<main>
<div class="intro" lang="en" id="intro-en">
<p class="eyebrow">THE MODEL WE TRAINED</p>
<h1>A small mind.<br>Your machine.</h1>
<p class="lede">Give our language model a beginning. It writes the next part entirely in your browser.</p>
</div>
<div class="intro" lang="fi" id="intro-fi" hidden>
<p class="eyebrow">ITSE KOULUTTAMAMME MALLI</p>
<h1>Pieni malli.<br>Sinun koneesi.</h1>
<p class="lede">Anna kielimallille tarinan alku. Malli jatkaa tekstiä kokonaan selaimessasi.</p>
</div>
<div class="facts"><span><b id="model-size">19.4M</b> <span data-en="parameters" data-fi="parametria">parameters</span></span><span>512 <span data-en="bytes of context" data-fi="tavun konteksti">bytes of context</span></span><span id="model-state">Trained model</span></div>
<section class="playground" aria-label="Text generation">
<form id="form">
<label for="model-choice" data-en="Model" data-fi="Malli">Model</label>
<select id="model-choice">
<option value="small" selected data-parameters="19.4M" data-download-en="Downloads 16.15 MB of compressed weights; 77.8 MB of runtime weights, plus buffers and KV cache. Your prompt stays on this device." data-download-fi="Lataa 16,15 Mt pakattuja painoja; painot vievät ajon aikana 77,8 Mt, minkä lisäksi tarvitaan puskurit ja KV-välimuisti. Kehote pysyy tällä laitteella." data-en="19.4M — default" data-fi="19,4M — oletus">19.4M — default</option>
<option value="large" data-parameters="152.2M" data-download-en="Downloads 123.5 MB of compressed weights; 608.8 MB of runtime weights, plus buffers and up to 48 MiB of KV cache. Requires substantially more memory and may run slowly on phones. Your prompt stays on this device." data-download-fi="Lataa 123,5 Mt pakattuja painoja; painot vievät ajon aikana 608,8 Mt, minkä lisäksi tarvitaan puskurit ja enintään 48 MiB KV-välimuistia. Vaatii selvästi enemmän muistia ja voi toimia puhelimessa hitaasti. Kehote pysyy tällä laitteella." data-en="152.2M — checkpoint 20,000" data-fi="152,2M — tarkistuspiste 20 000">152.2M — checkpoint 20,000</option>
</select>
<label for="prompt" data-en="Start a story" data-fi="Aloita tarina">Start a story</label>
<textarea id="prompt" rows="3" maxlength="2048" spellcheck="false">Far far away</textarea>
<div class="settings">
<label for="count"><span data-en="New bytes" data-fi="Uusia tavuja">New bytes</span><input id="count" type="number" min="1" max="500" value="200" required></label>
<label for="temperature"><span data-en="Temperature" data-fi="Lämpötila">Temperature</span><input id="temperature" type="number" min="0.1" max="2" step="0.1" value="0.8" required></label>
<label for="seed"><span data-en="Seed" data-fi="Siemen">Seed</span><input id="seed" type="number" min="0" max="4294967295" value="42" required></label>
</div>
<div class="actions"><button id="generate" type="submit" data-en="Generate on this device" data-fi="Luo teksti tällä laitteella">Generate on this device</button><button id="stop" type="button" hidden data-en="Stop" data-fi="Pysäytä">Stop</button></div>
<p class="small" id="download-info"></p>
</form>
<p id="status" role="status" aria-live="polite" data-en="Ready when you are." data-fi="Valmiina, kun sinä olet.">Ready when you are.</p>
<progress id="loading" max="1" value="0" hidden aria-label="Model download"></progress>
<pre id="output" aria-label="Generated text"></pre>
</section>
<section class="remember">
<h2 data-en="What it remembers" data-fi="Mitä malli muistaa">What it remembers</h2>
<p data-en="The prompt is processed together in one prefill pass. Then each new byte reuses the keys and values already calculated—the KV cache below." data-fi="Kehote käsitellään kerralla prefill-vaiheessa. Jokainen uusi tavu hyödyntää jo laskettuja avaimia ja arvoja eli alla näkyvää KV-välimuistia.">The prompt is processed together in one prefill pass. Then each new byte reuses the keys and values already calculated—the KV cache below.</p>
<div class="metrics">
<div><span data-en="Prompt prefill" data-fi="Kehotteen prefill">Prompt prefill</span><strong id="prefill">—</strong></div>
<div><span data-en="Generation" data-fi="Tekstin luonti">Generation</span><strong id="speed">—</strong></div>
<div><span data-en="KV cache" data-fi="KV-välimuisti">KV cache</span><strong id="memory">0 MiB</strong></div>
<div><span data-en="Runtime" data-fi="Suoritus">Runtime</span><strong id="backend">—</strong></div>
</div>
<div class="cache-track" role="progressbar" aria-label="Cached context" aria-valuemin="0" aria-valuemax="512" aria-valuenow="0"><div id="cache-fill"></div></div>
<p id="positions" class="small">0 / 512</p>
<p class="small" data-en="This model reads UTF-8 bytes, not words. Generation stops at a total of 512 bytes; it does not silently forget the beginning." data-fi="Malli lukee UTF-8-tavuja, ei sanoja. Tekstin luonti päättyy 512 tavun kohdalla; mallilta ei poisteta huomaamatta tekstin alkua.">This model reads UTF-8 bytes, not words. Generation stops at a total of 512 bytes; it does not silently forget the beginning.</p>
</section>
<p><a href="/small-language-model/" data-en="How we trained these models →" data-fi="Näin koulutimme mallit →">How we trained these models →</a></p>
<footer data-en="An experiment from ai.vostman.fi. Inference stays on your device. Optional Google Analytics measures visits, not your prompts or generated text." data-fi="Sivuston ai.vostman.fi kokeilu. Malli toimii laitteellasi. Vapaaehtoinen Google Analytics mittaa käyntejä, ei kehotteita tai luotua tekstiä.">An experiment from ai.vostman.fi. Inference stays on your device. Optional Google Analytics measures visits, not your prompts or generated text.</footer>
</main>
<script src="app.js?v=final-20000-1"></script>
</body>
</html>
browser/style.css
Raw:root{color-scheme:light;--bg:#f5f3ec;--surface:#fffef9;--ink:#192d29;--muted:#5d6c65;--line:#d5dcd1;--accent:#21684e;--soft:#e7eee3}
:root[data-theme=dark]{color-scheme:dark;--bg:#14201d;--surface:#1b2b26;--ink:#e6ece0;--muted:#aab9ad;--line:#3e5145;--accent:#92d5ad;--soft:#283e31}
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--bg);color:var(--ink);font:17px/1.65 system-ui,sans-serif}header{max-width:1180px;margin:auto;padding:22px 28px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:12px}.brand{font-weight:750;letter-spacing:-.04em;text-decoration:none;color:var(--ink)}nav{justify-self:end;display:flex;gap:4px}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:9px 16px;background:var(--surface);color:var(--ink)}nav button,.quiet{padding:4px 10px;background:transparent}nav [aria-pressed=true]{background:var(--soft);font-weight:700}button:disabled{cursor:wait;opacity:.65}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}main{max-width:900px;margin:auto;padding:30px 28px 45px}.eyebrow{font-size:12px;letter-spacing:.18em;font-weight:750;color:var(--accent);margin-bottom:20px}h1{font-family:Georgia,serif;font-size:clamp(48px,8vw,78px);line-height:1.06;letter-spacing:-.055em;font-weight:500;margin:0 0 24px}.lede{font-size:21px;max-width:650px;color:var(--muted)}.facts{display:flex;flex-wrap:wrap;gap:10px 25px;margin:28px 0;font-size:13px;color:var(--muted)}.facts>span{border-bottom:1px solid var(--line);padding-bottom:6px}.playground{background:var(--surface);padding:28px;border:1px solid var(--line);border-radius:16px}form>label{display:block;font-weight:650;margin-bottom:10px}textarea{display:block;resize:vertical;width:100%;min-height:115px;border:1px solid var(--line);border-radius:8px;padding:14px;background:var(--bg);color:var(--ink)}.settings{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:18px 0}.settings label{font-size:13px;color:var(--muted)}input{display:block;width:100%;margin-top:4px;padding:7px 10px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink)}.actions{display:flex;gap:10px;align-items:center}#generate{background:var(--ink);color:var(--surface);border-color:var(--ink);font-weight:650}.small{font-size:13px;line-height:1.6;color:var(--muted)}#status{font-size:13px;color:var(--accent);margin:20px 0 10px}progress{width:100%;accent-color:var(--accent)}#output{font:19px/1.8 Georgia,serif;white-space:pre-wrap;overflow-wrap:anywhere;margin:18px 0 0;min-height:70px}.remember{padding-top:32px}h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.035em;margin:0 0 12px}.remember>p{color:var(--muted);max-width:760px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:26px 0}.metrics div{border-top:1px solid var(--line);padding-top:12px}.metrics span{display:block;font-size:12px;color:var(--muted)}.metrics strong{display:block;font-size:21px;font-weight:600;margin-top:4px}.cache-track{background:var(--soft);height:12px;border-radius:20px;overflow:hidden}#cache-fill{height:100%;width:0;background:var(--accent);transition:width .1s linear}#positions{text-align:right;margin:6px 0}footer{margin-top:45px;padding-top:18px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}@media(max-width:550px){header{padding:16px}main{padding:20px 16px}.playground{padding:18px}.metrics{grid-template-columns:repeat(2,1fr)}.settings{gap:10px}.lede{font-size:18px}#output{font-size:18px}}
select{font:inherit;width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);margin-bottom:20px}select:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
browser/app.js
Rawconst $ = id => document.getElementById(id);
let worker, loaded = false, language = "en", generating = false;
const words = {
en: {loading: "Loading model", working: "Writing on your device…", ready: "Ready when you are.", done: "Generation finished.", stopped: "Stopped.", bytes: "bytes/s"},
fi: {loading: "Ladataan mallia", working: "Kirjoitetaan tällä laitteella…", ready: "Valmiina, kun sinä olet.", done: "Tekstin luonti päättyi.", stopped: "Pysäytetty.", bytes: "tavua/s"}
};
function setLanguage(value) {
language = value;
document.documentElement.lang = value;
$("intro-en").hidden = value !== "en";
$("intro-fi").hidden = value !== "fi";
for (const node of document.querySelectorAll("[data-en]")) node.textContent = node.dataset[value];
for (const lang of ["en", "fi"]) $(lang).setAttribute("aria-pressed", lang === value);
updateModelInfo();
if (generating) $("status").textContent = words[language].working;
}
$("en").onclick = () => setLanguage("en");
$("fi").onclick = () => setLanguage("fi");
$("theme").onclick = () => {
document.documentElement.dataset.theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
};
function finish() {
generating = false;
$("generate").disabled = false;
$("stop").hidden = true;
$("model-choice").disabled = false;
$("loading").hidden = true;
}
function generate() {
generating = true;
$("model-choice").disabled = true;
$("generate").disabled = true;
$("stop").hidden = false;
$("status").textContent = words[language].working;
$("output").textContent = $("prompt").value;
$("prefill").textContent = $("speed").textContent = "—";
worker.postMessage({type: "generate", prompt: $("prompt").value,
count: Number($("count").value), temperature: Number($("temperature").value), seed: Number($("seed").value)});
}
function load() {
$("model-choice").disabled = true;
$("generate").disabled = true;
$("status").textContent = words[language].loading;
$("loading").value = 0;
$("loading").hidden = false;
$("stop").hidden = false;
worker = new Worker("worker.js?v=final-20000-1");
worker.onmessage = ({data}) => {
if (data.type === "loading") {
$("loading").value = data.loaded / data.total;
$("status").textContent = `${words[language].loading}: ${(data.loaded / 1e6).toFixed(1)} / ${(data.total / 1e6).toFixed(1)} MB`;
}
if (data.type === "ready") {
loaded = true;
$("loading").hidden = true;
$("backend").textContent = data.backend === "wasm" ? "WebAssembly" : "WebGPU";
generate();
}
if (data.type === "token") {
$("output").textContent = data.text;
$("prefill").textContent = `${data.prefillMs.toFixed(0)} ms`;
$("speed").textContent = data.bytesPerSecond ? `${data.bytesPerSecond.toFixed(1)} ${words[language].bytes}` : "—";
$("memory").textContent = `${(data.cacheBytes / 1048576).toFixed(2)} MiB`;
$("cache-fill").style.width = `${data.positions / 512 * 100}%`;
$("cache-fill").parentElement.setAttribute("aria-valuenow", data.positions);
$("positions").textContent = `${data.positions} / 512`;
}
if (data.type === "done") {
$("output").textContent = data.text;
$("status").textContent = data.stopped ? words[language].stopped : words[language].done;
finish();
}
if (data.type === "error") {
$("status").textContent = data.message;
finish();
if (!loaded) { worker.terminate(); worker = null; }
}
};
worker.onerror = event => {
$("status").textContent = event.message || "Browser worker failed";
finish(); worker.terminate(); worker = null; loaded = false;
};
worker.postMessage({type: "load", model: $("model-choice").value, backend: new URLSearchParams(location.search).get("backend") || "auto"});
}
$("form").onsubmit = event => {
event.preventDefault();
const length = new TextEncoder().encode($("prompt").value).length;
if (!length || length >= 512) {
$("status").textContent = language === "en" ? "Use a prompt of 1–511 UTF-8 bytes." : "Kehotteen pituuden on oltava 1–511 UTF-8-tavua.";
return;
}
if (loaded) generate(); else load();
};
$("stop").onclick = () => {
if (!loaded) {
worker.terminate();
worker = null;
$("status").textContent = words[language].stopped;
finish();
} else worker.postMessage({type: "stop"});
};
function updateModelInfo() {
const option = $("model-choice").selectedOptions[0];
$("model-size").textContent = option.dataset.parameters;
$("download-info").textContent = option.dataset[language === "en" ? "downloadEn" : "downloadFi"];
$("model-state").textContent = language === "en" ? "Trained model" : "Koulutettu malli";
}
$("model-choice").onchange = () => {
if (worker) worker.terminate();
worker = null;
loaded = false;
$("output").textContent = "";
$("prefill").textContent = $("speed").textContent = $("backend").textContent = "—";
$("memory").textContent = "0 MiB";
$("cache-fill").style.width = "0%";
$("cache-fill").parentElement.setAttribute("aria-valuenow", "0");
$("positions").textContent = "0 / 512";
$("status").textContent = words[language].ready;
updateModelInfo();
};
setLanguage("en");
browser/worker.js
RawimportScripts("vendor/tf.min.js", "vendor/tf-backend-wasm.js", "model.js");
let model, running = false, stopped = false;
async function load(backend, selection = "small") {
const directory = selection === "large" ? "model/large/" : "model/";
if (backend === "auto") {
const adapter = await self.navigator.gpu?.requestAdapter().catch(() => null);
const software = adapter?.isFallbackAdapter || adapter?.info?.isFallbackAdapter ||
/swiftshader|software/i.test(adapter?.info?.description || "");
backend = adapter && !software ? "webgpu" : "wasm";
}
if (backend === "webgpu") {
try {
importScripts("vendor/tf-backend-webgpu.js");
if (!await tf.setBackend("webgpu")) throw new Error("WebGPU unavailable");
await tf.ready();
} catch (error) {
backend = "wasm";
}
}
if (backend === "wasm") {
tf.wasm.setWasmPaths("vendor/");
tf.wasm.setThreadsCount(1);
await tf.setBackend("wasm");
await tf.ready();
}
const config = await (await fetch(directory + "model-int8.json", {cache: "no-cache"})).json();
const response = await fetch(directory + config.weight_file);
if (!response.ok) throw new Error("Model download failed");
const reader = response.body.getReader();
const bytes = new Uint8Array(config.weight_bytes);
let loaded = 0;
for (;;) {
const {value, done} = await reader.read();
if (done) break;
bytes.set(value, loaded);
loaded += value.length;
postMessage({type: "loading", loaded, total: config.weight_bytes});
}
if (loaded !== bytes.length) throw new Error("Incomplete model download");
config.directory = directory;
model = new TinyLM(config, tf.io.decodeWeights(bytes.buffer, config.weights));
const warmup = model.step([32]);
await warmup.logits.data();
tf.dispose(warmup);
postMessage({type: "ready", backend: tf.getBackend(), config});
}
function random(seed) {
return () => {
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function choose(logits, temperature, rng) {
const maximum = Math.max(...logits);
const probabilities = Array.from(logits, x => Math.exp((x - maximum) / temperature));
let target = rng() * probabilities.reduce((a, b) => a + b, 0);
for (let i = 0; i < probabilities.length; i++) {
target -= probabilities[i];
if (target <= 0) return i;
}
return 255;
}
async function generate({prompt, count, temperature, seed}) {
const input = Array.from(new TextEncoder().encode(prompt));
if (!input.length || input.length >= model.config.context) throw new Error("Prompt must use 1–511 UTF-8 bytes");
const limit = Math.min(count, model.config.context - input.length);
stopped = false;
let state;
const start = performance.now();
try {
state = model.step(input);
let logits = await state.logits.data();
const prefillMs = performance.now() - start;
const decoding = performance.now();
const decoder = new TextDecoder();
const rng = random(seed);
let text = prompt;
for (let i = 0; i < limit && !stopped; i++) {
const byte = choose(logits, temperature, rng);
text += decoder.decode(new Uint8Array([byte]), {stream: true});
const positions = state.cache[0].key.shape[1];
postMessage({type: "token", text, generated: i + 1, positions, prefillMs,
bytesPerSecond: i ? i * 1000 / (performance.now() - decoding) : 0,
cacheBytes: positions * model.config.layers * 2 * model.config.width * 4});
if (i + 1 < limit && !stopped) {
const previous = state;
state = model.step([byte], previous.cache);
tf.dispose(previous);
logits = await state.logits.data();
}
if (i % 4 === 0) await new Promise(resolve => setTimeout(resolve, 0));
}
text += decoder.decode();
postMessage({type: "done", text, stopped});
} finally {
if (state) tf.dispose(state);
}
}
async function verify() {
const {cases} = await (await fetch(model.config.directory + model.config.verification_file, {cache: "no-cache"})).json();
let maximumError = 0, checks = 0;
const baseline = tf.memory().numTensors;
for (const test of cases) {
let state;
try {
for (const reference of test.steps) {
const previous = state;
state = model.step(reference.input, previous ? previous.cache : []);
if (previous) tf.dispose(previous);
const actual = await state.logits.data();
for (let i = 0; i < actual.length; i++) {
if (!Number.isFinite(actual[i])) throw new Error("Non-finite browser logits");
maximumError = Math.max(maximumError, Math.abs(actual[i] - reference.logits[i]));
}
checks++;
}
} finally {
if (state) tf.dispose(state);
}
}
const leaked = tf.memory().numTensors - baseline;
if (maximumError > 0.002 || leaked) throw new Error(`Verification failed: error=${maximumError}, tensors=${leaked}`);
postMessage({type: "verified", checks, maximumError, leaked, backend: tf.getBackend()});
}
self.onmessage = async ({data}) => {
if (data.type === "stop") { stopped = true; return; }
if (running) return;
running = true;
try {
if (data.type === "load") await load(data.backend || "auto", data.model);
if (data.type === "generate") await generate(data);
if (data.type === "verify") await verify();
} catch (error) {
postMessage({type: "error", message: error.message});
} finally {
running = false;
}
};
browser/model.js
Rawclass TinyLM {
constructor(config, weights) {
this.config = config;
this.weights = weights;
}
norm(x, name) {
const {mean, variance} = tf.moments(x, -1, true);
return x.sub(mean).mul(variance.add(this.config.epsilon).rsqrt())
.mul(this.weights[`${name}.gamma`]).add(this.weights[`${name}.beta`]);
}
dense(x, name) {
return tf.matMul(x, this.weights[`${name}.kernel`]).add(this.weights[`${name}.bias`]);
}
step(bytes, cache = []) {
const {width, heads, layers, context} = this.config;
const offset = cache.length ? cache[0].key.shape[1] : 0;
const length = bytes.length;
if (!length || offset + length > context) throw new Error("Context limit reached");
return tf.tidy(() => {
const ids = tf.tensor1d(bytes, "int32");
let x = tf.gather(this.weights.tokens, ids)
.add(tf.gather(this.weights.positions, tf.range(offset, offset + length, 1, "int32")));
const next = [];
for (let i = 0; i < layers; i++) {
const name = `b${i}`;
const normalized = this.norm(x, `${name}.norm1`);
const project = part => this.dense(normalized, `${name}.${part}`)
.reshape([length, heads, width / heads]).transpose([1, 0, 2]);
const query = project("q");
let key = project("k"), value = project("v");
if (cache.length) {
key = tf.concat([cache[i].key, key], 1);
value = tf.concat([cache[i].value, value], 1);
}
next.push({key, value});
let scores = tf.matMul(query, key, false, true).mul(1 / Math.sqrt(width / heads));
if (length > 1) {
const future = tf.range(0, offset + length, 1, "int32").expandDims(0)
.greater(tf.range(offset, offset + length, 1, "int32").expandDims(1));
scores = scores.add(future.cast("float32").mul(-1e9));
}
const attention = tf.matMul(tf.softmax(scores, -1), value)
.transpose([1, 0, 2]).reshape([length, width]);
x = x.add(this.dense(attention, `${name}.o`));
const expanded = this.dense(this.norm(x, `${name}.norm2`), `${name}.expand`);
const gelu = expanded.mul(0.5).mul(tf.erf(expanded.div(Math.SQRT2)).add(1));
x = x.add(this.dense(gelu, `${name}.contract`));
}
const last = this.norm(x.slice([length - 1, 0], [1, width]), "norm");
return {logits: this.dense(last, "head").reshape([256]), cache: next};
});
}
}
self.TinyLM = TinyLM;
browser/vendor.json
Raw{
"tf.min.js": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js",
"bytes": 1469843,
"sha256": "300dfae273d20b4046f46a06d735688f03675a807561e9bcb5f664eb2f3d2831"
},
"tf-backend-wasm.js": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.js",
"bytes": 484314,
"sha256": "55f8a1b3d9ae88d108975303708b999463601f193fd9117b57b5417574f1bd43"
},
"tf-backend-webgpu.js": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.js",
"bytes": 811133,
"sha256": "e4d8f36f76f24c2c614a81ae823ac8d6538057738bc1c327102e381ab469898b"
},
"tfjs-backend-wasm.wasm": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tfjs-backend-wasm.wasm",
"bytes": 311123,
"sha256": "70a5d516060464e5269f01c74bac1772d6b8ab6cb612acf16b5cdaf61f78d892"
},
"tfjs-backend-wasm-simd.wasm": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tfjs-backend-wasm-simd.wasm",
"bytes": 424594,
"sha256": "77ebb28a6d34f371dbbf2086b7f2de8994acd8ea5a3cf1fa24d2c26c840cac7b"
},
"tfjs-backend-wasm-threaded-simd.wasm": {
"url": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tfjs-backend-wasm-threaded-simd.wasm",
"bytes": 435643,
"sha256": "c052228d4bef185c27bbe59a9e029570c78bbb9f08b3cb46b597851650373de2"
},
"LICENSE": {
"url": "https://raw.githubusercontent.com/tensorflow/tfjs/tfjs-v4.22.0/LICENSE",
"bytes": 11358,
"sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
}
}
browser/fetch_runtime.py
Raw"""Fetch the pinned TensorFlow.js runtime beside the browser files."""
import hashlib
import json
from pathlib import Path
import urllib.request
root = Path(__file__).resolve().parent
vendor = root / "vendor"
vendor.mkdir(exist_ok=True)
manifest = json.loads((root / "vendor.json").read_text())
for name, entry in manifest.items():
with urllib.request.urlopen(entry["url"], timeout=60) as response:
content = response.read()
if hashlib.sha256(content).hexdigest() != entry["sha256"]:
raise ValueError(f"Checksum mismatch: {name}")
(vendor / name).write_bytes(content)
print(name)