Live model · Designs by Duhart
A real encoder-decoder Transformer, two layers and four heads, written in C++ and compiled to WebAssembly, running in this page on the bytes you type. It reads a daemon sequence and returns it mirrored, and attention is drawn as beams between the two stacks while it works.
Fetching breach.wasm and the weights.
In Cyberpunk 2077 there is a hacking minigame called Breach Protocol. You get a grid of two-character codes like 1C and BD, and you pick them in the right order to upload a program. The deck above is my version of that. You give it a row of codes, and it answers with the same row backwards.
The thing answering is a real Transformer. That is the same kind of program that sits inside the chatbots you have used, except this one is tiny: 44,335 numbers instead of billions. I wrote it in C++, taught it the mirror trick in 85 seconds on my own machine, and compiled that same code to run in your browser. Nothing on the deck is a cartoon of what a Transformer does. It is the model doing it, on the codes you typed.
This book walks through it one piece at a time. Every chapter shows the actual lines of code that do that piece, and most chapters have a button that jumps the deck to the moment they describe. You do not need to know any math beyond multiplying and adding.
A Transformer does not read letters or words. It reads numbered tokens. My model knows fifteen of them: the twelve codes on the grid (1C, 55, 7A, BD, E9, FF, 3F, A0, C4, 2B, 8E, D6), plus three it needs for housekeeping. BOS means "begin", and it is always the first thing the decoder sees. EOS means "end", and the model has to learn to say it, or it would never stop. PAD is a blank; this model never actually uses it.
So a run looks like this. You type 1C 55 7A, which the model sees as the numbers 3 4 5. It answers 5 4 3 and then EOS. The deck shows 7A 55 1C in the buffer and a small end marker. The whole job is one C function, bp_run: codes in, codes out.
/* * Greedy-decode src[0..n) (byte tokens, 1 <= n <= BP_MAXLEN). Writes the produced tokens to * out[] (EOS included when produced, never BOS) and returns how many decode steps ran, which * is also how many tokens were written. Returns -1 on a bad n. Every intermediate the * readers below expose is recomputed and kept until the next bp_run. */ int bp_run(const int* src, int n, int* out, int max_out);
native/breach.h from line 74 · the one call the page makes
A computer cannot do math on the code 1C, so the first step turns each code into a list of 32 numbers. That list is called a vector, and the 32 lists (one per code) are stored in a table the model learned during training. The table is the model's dictionary: 1C means "row 3 of the table", and that is all it means.
There is a catch. If every 1C turned into the same 32 numbers, the model could not tell a 1C in slot 0 from a 1C in slot 5, and you cannot mirror a row if you do not know where anything is. So the model adds a second vector that depends only on the slot number. It is built from sine and cosine waves at different speeds, so every slot gets a different pattern, like the hands of a clock. That is the bp_pe function, and it is the whole reason the deck shows a separate signal rising from each cell.
void bp_pe(int pos, real* row) {
for (int i = 0; i < D; i += 2) {
double div = pow(10000.0, (double)i / (double)D);
row[i] = (real)sin((double)pos / div);
if (i + 1 < D) row[i + 1] = (real)cos((double)pos / div);
}
}
static void embed(int table, const int* tok, int n, real* x0) {
const real s = (real)sqrt((double)D);
real pe[D];
for (int i = 0; i < n; i++) {
bp_pe(i, pe);
const real* e = g_W + table + tok[i] * D;
for (int j = 0; j < D; j++) x0[i * D + j] = e[j] * s + pe[j];
}native/model.cpp from line 193 · the clock for position, then table row × √32 plus the clock
This is the trick the whole thing is named after. Each slot in the row gets to look at every other slot and decide how much to care about it. Here is how it does that with nothing but multiplication.
Each slot's vector is turned into three new vectors. Call them the question, the label, and the contents. To decide how much slot 2 should care about slot 5, the model multiplies slot 2's question by slot 5's label, number by number, and adds up the results. A big total means "these two match". It does this for every pair, so a row of six codes produces a six by six grid of scores.
Then each row of scores goes through a function called softmax, which turns any list of numbers into percentages that add up to 100. Those percentages are the beams on the deck. A thick bright beam is a big percentage. Finally each slot collects the contents of every other slot, weighted by those percentages, and that weighted mix becomes its new vector. Slot 2 now carries a bit of whatever it decided to look at.
static void attention(const real* xq, int tq, const real* xkv, int tk, const AttnW& w, bool causal, AttnCache& c) {
const real* W = g_W;
linear(xq, tq, D, W + w.wq, W + w.bq, D, c.q);
linear(xkv, tk, D, W + w.wk, W + w.bk, D, c.k);
linear(xkv, tk, D, W + w.wv, W + w.bv, D, c.v);
const real scale = (real)(1.0 / sqrt((double)DK));
for (int h = 0; h < H; h++) {
real* s = c.s[h];
for (int i = 0; i < tq; i++) {
const real* qi = c.q + i * D + h * DK;
for (int j = 0; j < tk; j++) {
if (causal && j > i) { s[i * tk + j] = (real)-1e9; continue; }
const real* kj = c.k + j * D + h * DK;
real dot = 0;
for (int e = 0; e < DK; e++) dot += qi[e] * kj[e];
s[i * tk + j] = dot * scale;
}
}
memcpy(c.p[h], s, sizeof(real) * tq * tk);
softmax_rows(c.p[h], tq, tk);
const real* p = c.p[h];
for (int i = 0; i < tq; i++) {
real* ci = c.ctx + i * D + h * DK;
for (int e = 0; e < DK; e++) ci[e] = 0;
for (int j = 0; j < tk; j++) {
real pij = p[i * tk + j];
if (pij == 0) continue;
const real* vj = c.v + j * D + h * DK;
for (int e = 0; e < DK; e++) ci[e] += pij * vj[e];
}
}
}
linear(c.ctx, tq, D, W + w.wo, W + w.bo, D, c.out);
}native/model.cpp from line 143 · questions, labels and contents; scores; softmax; the weighted mix
Softmax shows up twice in this model, so it gets its own page. Give it a list of scores, any size, negative or positive, and it gives back a list of percentages that add up to 100. The rule is: raise the number e to each score, then divide by the total. Big scores get most of the percentage, and because of the e, a score that is a little bigger gets a lot more. That is why one beam usually dominates.
The line that subtracts the biggest score first is not part of the math. It is there because e to a large power overflows a computer's number, and subtracting the maximum gives the same percentages without the overflow. Most of the "extra" lines in real model code are like this: not the idea, just keeping the numbers safe.
static void softmax_rows(real* s, int r, int c) {
for (int i = 0; i < r; i++) {
real* row = s + i * c;
real mx = row[0];
for (int j = 1; j < c; j++) if (row[j] > mx) mx = row[j];
real sum = 0;
for (int j = 0; j < c; j++) { row[j] = (real)exp((double)(row[j] - mx)); sum += row[j]; }
for (int j = 0; j < c; j++) row[j] /= sum;
}native/model.cpp from line 126 · one row at a time
Attention as I described it asks one question per slot. The real model asks four at once. It cuts each 32-number vector into four pieces of 8, runs attention on each piece separately, and glues the four answers back together. Each piece is called a head, and the H1 to H4 buttons on the deck switch between them.
Why bother? Because one question cannot capture everything. In language models, one head might learn to look at the previous word while another looks for the subject of the sentence. In this toy, you can see the heads disagree: switch between them during the cross-attention beat and some draw the X cleanly while others spread their beams around. The model does not need all four to be right, just enough of them.
In the code this is the for (int h = 0; h < H; h++) loop in the attention function above. The h * DK offsets pick out head number h's 8 numbers. There is no separate code for "a head". It is the same attention, four times, on four slices.
The model has two halves. The encoder reads your row all at once. The decoder writes the answer one code at a time, and while it is deciding code number 3 it is only allowed to look at codes 0, 1 and 2. If it could see code 4, it could cheat during training by reading the answer, and then it would be useless when there is no answer to read.
The fix is two lines. Before the softmax, every score that points at a later slot is replaced with minus one billion. Raise e to minus one billion and you get zero, so the softmax hands those slots exactly zero percent. That is why the decoder's beams on the deck only ever point left, and why a grid of decoder scores always has an empty triangle in the top right.
if (causal && j > i) { s[i * tk + j] = (real)-1e9; continue; }native/model.cpp from line 155 · inside the attention loop: key j after query i gets -1e9
After attention, each slot gets a moment to think on its own. That is the feed-forward step: multiply the vector by a table to make it wider (64 numbers), throw away anything negative, then multiply by another table to bring it back to 32. It runs the same way on every slot, and it is where a lot of the model's actual "knowledge" ends up stored.
Two small habits make this stable enough to train. First, the model never replaces a slot's vector; it adds the new result to the old one. That is the add call, and it means a layer can leave a slot alone by outputting zeros. Second, before attention and before the think step, the vector is normalised: shifted and scaled so its 32 numbers average zero and spread about one. That is layernorm, and without it the numbers drift out of range after a few layers. My model does this in the order Harvard's Annotated Transformer uses: normalise first, then the step, then add.
void fwd_encoder(const int* src, int n, EncCache& c) {
build_layout();
const Layout& L = g_layout;
c.n = n;
for (int i = 0; i < n; i++) c.tok[i] = src[i];
embed(L.src_embed, src, n, c.x0);
const real* x = c.x0;
for (int l = 0; l < NL; l++) {
EncLayerCache& lc = c.L[l];
const EncW& w = L.enc[l];
memcpy(lc.in, x, sizeof(real) * n * D);
layernorm(lc.in, n, g_W + w.ln1.g, g_W + w.ln1.b, lc.n1, lc.mu1, lc.rs1);
attention(lc.n1, n, lc.n1, n, w.attn, false, lc.attn);
add(lc.in, lc.attn.out, n * D, lc.x1);
layernorm(lc.x1, n, g_W + w.ln2.g, g_W + w.ln2.b, lc.n2, lc.mu2, lc.rs2);
ffn(lc.n2, n, w.ffn, lc.h, lc.hr, lc.f);
add(lc.x1, lc.f, n * D, lc.out);
x = lc.out;
}
layernorm(x, n, g_W + L.enc_norm.g, g_W + L.enc_norm.b, c.mem, c.muf, c.rsf);
}native/model.cpp from line 220 · one encoder layer, twice: normalise, attend, add; normalise, think, add
When the encoder finishes, its output is a stack of vectors, one per code you typed. I call that the memory, and on the deck it is the slab on top of the front stack. The decoder reads it with the same attention trick, with one change: the questions come from the decoder's slots, and the labels and contents come from the memory. That is cross-attention, and it is the beams that cross the gap between the two stacks.
This is where the mirror task earns its keep. To write output code number 0, the decoder needs the last code you typed. For output 1 it needs the second to last. So a decoder that has learned the job draws beams that go backwards through the memory, and when the deck leaves each finished slot's strongest beam in place, they add up to an X. If you ever wanted to see what "the model learned it" looks like, that X is it.
layernorm(lc.in, t, g_W + w.ln1.g, g_W + w.ln1.b, lc.n1, lc.mu1, lc.rs1); attention(lc.n1, t, lc.n1, t, w.self, true, lc.self); add(lc.in, lc.self.out, t * D, lc.x1); layernorm(lc.x1, t, g_W + w.ln2.g, g_W + w.ln2.b, lc.n2, lc.mu2, lc.rs2); attention(lc.n2, t, e.mem, n, w.cross, false, lc.cross); add(lc.x1, lc.cross.out, t * D, lc.x2);
native/model.cpp from line 256 · inside one decoder layer: masked self-attention, then attention over the memory
At the end of the decoder, the last slot's vector is multiplied by one more table to produce fifteen scores, one per token the model knows. Softmax turns them into percentages, and those are the bars on top of the back stack. The model picks the tallest bar. That is it. There is no rule anywhere that says "reverse the input"; there are just fifteen bars and whichever is tallest.
Then the loop goes around: the picked code is added to the decoder's input, the decoder runs again on the longer input, and it picks the next one. It stops when the tallest bar is EOS. Picking the tallest bar every time is called greedy decoding. Chatbots do something fancier, sampling from the bars instead of always taking the top one, which is why they can answer the same question differently twice. For a mirror, greedy is exactly right.
int tokens[ML];
tokens[0] = BP_BOS;
int t = 1;
int steps = 0;
for (int step = 0; step < max_out; step++) {
fwd_decoder(g_enc, tokens, t, g_dec);
for (int l = 0; l < NL; l++) for (int h = 0; h < H; h++) {
copy_f(g_dec.L[l].self.p[h], t * t, T_self_p[step][l][h]);
copy_f(g_dec.L[l].self.s[h], t * t, T_self_s[step][l][h]);
copy_f(g_dec.L[l].cross.p[h], t * n, T_cross_p[step][l][h]);
copy_f(g_dec.L[l].cross.s[h], t * n, T_cross_s[step][l][h]);
}
const real* last = g_dec.logits + (t - 1) * V;
copy_f(last, V, T_logits[step]);
/* softmax of the last row, in float */
float mx = T_logits[step][0];
for (int j = 1; j < V; j++) if (T_logits[step][j] > mx) mx = T_logits[step][j];
float sum = 0;
for (int j = 0; j < V; j++) { T_probs[step][j] = (float)exp((double)(T_logits[step][j] - mx)); sum += T_probs[step][j]; }
int next = 0;
for (int j = 0; j < V; j++) { T_probs[step][j] /= sum; if (T_probs[step][j] > T_probs[step][next]) next = j; }
T_next[step] = next;
out[step] = next;
steps++;
if (next == BP_EOS) break;
if (t >= ML) break;
tokens[t++] = next;native/model.cpp from line 315 · run the decoder, take the tallest bar, append it, repeat until EOS
Every table in this model started as random numbers. Training is how they became the right ones, and the idea is simpler than people make it sound. Show the model a row and the correct mirrored answer. Let it guess. Measure how wrong the guess was with a single number called the loss: it is large when the model gave the correct code a small percentage, and near zero when it gave the correct code nearly all of it.
Then, for every one of the 44,335 numbers in the model, work out whether nudging it up or down would have made the loss smaller. That is the gradient. Working it out means running the whole calculation backwards, which is what most of train.cpp is: every step in the forward pass has a matching step that pushes blame back through it. I wrote all of those by hand, and then checked them by literally nudging each number and measuring, because a wrong gradient does not crash. It just learns slowly and badly.
Finally, nudge every number a tiny bit in its better direction, and repeat with a fresh random row. The nudging rule is called Adam, and the tiny bit starts small, grows for the first 400 rounds, then shrinks. After 4,000 rounds of 32 rows each, 85 seconds on one CPU core, the model got every held-out row right, and I kept the numbers from round 3,250, the first time it did.
double loss = 0;
for (int i = 0; i < t; i++) {
const real* z = Dc.logits + i * V;
real mx = z[0];
for (int j = 1; j < V; j++) if (z[j] > mx) mx = z[j];
double sum = 0;
for (int j = 0; j < V; j++) sum += exp((double)(z[j] - mx));
for (int j = 0; j < V; j++) {
double p = exp((double)(z[j] - mx)) / sum;
dlog[i * V + j] = (real)((p - (j == tgt_out[i] ? 1.0 : 0.0)) * (double)scale);
}
loss += -((double)(z[tgt_out[i]] - mx) - log(sum));
}native/train.cpp from line 160 · how wrong: the loss, and the first step backwards, per output slot
Here is the update, the few lines that actually change the model. lr is how big a nudge to take this round, and it follows the schedule from the original paper: it climbs during the warmup so the random starting numbers do not get thrown around, then it eases off. m and v are running averages of the gradient and of its square, which is what makes it Adam rather than plain gradient descent: a number that keeps getting the same push moves faster, and one whose pushes are noisy moves slower. The last line is the nudge itself.
double lr = factor * pow((double)D, -0.5) * fmin(pow((double)step, -0.5), (double)step * pow((double)warmup, -1.5));
double c1 = 1 - pow(b1, step), c2 = 1 - pow(b2, step);
for (int i = 0; i < NPARAMS; i++) {
double g = (double)G[i];
m[i] = b1 * m[i] + (1 - b1) * g;
v[i] = b2 * v[i] + (1 - b2) * g * g;
W[i] -= (real)(lr * (m[i] / c1) / (sqrt(v[i] / c2) + eps));
}native/train.cpp from line 614 · the learning rate schedule and the Adam update, every round
The forward pass you have been reading is one C++ file. On my machine I compile it with g++ together with the trainer, and that program learns the model and writes the numbers out as a file. Then I compile the same file again with a different compiler, Emscripten, which turns C++ into WebAssembly: a compact format that every modern browser can run at close to native speed. The result is a 35 KB file with no dependencies at all. The page downloads it, hands it the numbers, and calls bp_run.
I did it this way because my first version had two models: one in JavaScript to train and one in TypeScript to draw. They had to agree on every detail, so I needed a test to catch them disagreeing. One source that compiles twice cannot disagree with itself, and the test went away. If you only take one engineering idea from this page, take that one.
echo "build: native trainer"
g++ -O2 -std=c++17 -Wall -DBP_TOOLCHAIN="\"${TOOLCHAIN}\"" -o native/build/train native/model.cpp native/train.cpp
echo "build: gradcheck (double, D=8 H=2 FF=8 L=1)"
g++ -O2 -std=c++17 -Wall -DBP_REAL=double -DBP_D=8 -DBP_FF=8 -DBP_HEADS=2 -DBP_LAYERS=1 -o native/build/gradcheck native/model.cpp native/train.cpp
echo "build: breach.wasm (em++ ${EMCC_VER})"
em++ -O2 -std=c++17 -fno-exceptions -fno-rtti -s STANDALONE_WASM=1 --no-entry \
-s INITIAL_MEMORY=4194304 -s ALLOW_MEMORY_GROWTH=0 \
-s EXPORTED_FUNCTIONS=_malloc,_free,_bp_param_count,_bp_config,_bp_load,_bp_run,_bp_enc_attn,_bp_enc_scores,_bp_dec_self_attn,_bp_dec_self_scores,_bp_cross_attn,_bp_cross_scores,_bp_logits,_bp_probs,_bp_next,_bp_memory \
-o public/models/breach.wasm native/model.cppnative/build.sh from line 18 · the same model.cpp, twice
The trained numbers travel as a file of about 121 KB. Each table is stored as half-size floats to save space, then the file lists them in the exact order the C++ expects. The browser's job is small: decode each table, lay them all end to end in one long array, copy that array into the module's memory, and call bp_load. The page never learns any table's name. It does not need to, because the file's order is the layout, and the C++ that wrote the file is the C++ that reads the array.
const total = paramCount(file);
if (total !== ex.bp_param_count()) throw new Error(`weights carry ${total} floats, the module wants ${ex.bp_param_count()}`);
const flat = new Float32Array(total);
let at = 0;
for (const t of file.tensors) { // in file order, which IS the layout
const n = t.shape.reduce((a, b) => a * b, 1);
const data = decodeF16Base64(t.f16); // half floats in base64 -> 32-bit floats
if (data.length !== n) throw new Error(`${t.name}: ${data.length} values for shape [${t.shape}]`);
flat.set(data, at);
at += n;
}
const wp = ex.malloc(total * 4); // room inside the module's own memory
new Float32Array(ex.memory.buffer, wp, total).set(flat);
if (ex.bp_load(wp, total) !== 0) throw new Error("bp_load refused the weights");
ex.free(wp);lib/transformer/wasm.ts from line 116 · the browser side, TypeScript
Every row the model saw in training was between 2 and 8 codes long. Try typing 9 or 10 into the deck. The position clock from chapter 02 can produce a pattern for slot 9, because it is a formula and not a table, but the model was never once asked what to do with that pattern. Usually the first few beams still go to the right place and then the X falls apart in the middle.
I left this in on purpose. It is the honest edge of what the model knows, and it shows something the accuracy table cannot: a model that scores 100% on the test can still be confidently wrong one step outside it. The big models have the same edge. It is just further away, and harder to find.
| Setting | Value |
|---|---|
| Shape | Encoder-decoder, 2 layers each, normalise-first |
| Vector size · heads · think width | 32 · 4 · 64 |
| Tokens | 15: 12 codes, plus PAD, BOS and EOS |
| Numbers in the model | 44,335 |
| Weights on disk | 121 KB, half-size floats in base64 |
| Built with | g++ 11 -O2 -std=c++17, native; em++ 6.0.9 -O2 STANDALONE_WASM for the page |
| Training | 4,000 rounds of 32, 85 seconds, keeping round 3,250 |
| Final loss | 5.4e-4 |
| Trained on | Random rows of 2 to 8 codes, mirrored |
| Row length | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|
| Correct | 100.0% | 100.0% | 100.0% | 100.0% | 100.0% | 100.0% | 100.0% |
The full engineering story, including the version of this that was too busy and the bug the tests caught, is in the post Breach Protocol: a Transformer you can read. The deck has a page of its own to share at /demos/breach/.
A two-layer, four-head encoder-decoder Transformer written in C++. The same source compiles natively to train and to WebAssembly to run, and the trained weights ship with the page. three.js draws the two stacks, and every beam between them is an attention weight the model just computed on your input.
Real: the model, the weights and every number on screen. It was trained for one narrow task, mirroring a byte sequence, so it is a small model doing one thing well and not a language model.
Written up in full: Breach Protocol: a Transformer you can readHow it is builtAll demos