From characters to coherent text — the architecture, the math, and the magic.
The pipeline end-to-end
Byte-level BPE
Tokens become vectors
Q, K, V & multi-head
Logits → next token
Can a small model learn next-character prediction while being unable to see future targets?
Understand the data, the rule, and the required result. Then run the complete Python program and modify one thing at a time.
No companion presentation is required. Python syntax and any mathematical notation are explained as they are introduced.
One character ID in this example.
Known earlier tokens provide context for next-token targets.
Only current/past positions may be attended to.
Append one prediction and use it as later context.
Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.
The neural computation includes matrix multiplications, lookups, normalization, nonlinear activations, and softmax. Tokenization and runtime control are additional algorithms.
"The cat sat"
"on" (P = 0.42) "down" (P = 0.21) "in" (P = 0.11) …
Decoder-only transformers are common for autoregressive text generation. Encoder-decoder and other designs also exist; undisclosed proprietary architectures should not be assumed.
BERT family
Sees: every token sees every other (full context).
Good at: classification, embeddings, search/retrieval, NER, fill-in-blank.
Bad at: generating text — no native left-to-right notion.
Examples: BERT, RoBERTa, DeBERTa, sentence-transformers
Original Transformer / T5 family
Sees: encoder bidirectional · decoder causal · decoder cross-attends to encoder.
Good at: translation, summarization, structured input → structured output.
Encoder-decoder cost depends on the allocation of encoder/decoder layers, width, and cross-attention. It is not intrinsically twice a decoder-only model at a fixed total parameter budget.
Examples: T5, FLAN-T5, BART, original Transformer (Vaswani 2017)
GPT family — modern LLMs
Sees: each token sees only past tokens (left-to-right).
Good at: text generation, chat, code, reasoning, agents — anything autoregressive.
Why it won: simpler, scales beautifully, one model handles all tasks via prompting.
Examples: GPT-4, Claude, Llama, Mistral, Gemini, Qwen, DeepSeek
Many chat systems use decoder-only transformers, but this is not a universal architectural requirement. Public model documentation is the basis for any named architecture claim.
Neural networks operate on numbers — not characters or words. Tokenization is the bridge.
"The cat sat on the mat."
[791, 8415, 7731, 389, 279, 5634, 13]
The model never sees "cat" — it sees the integer 8415.
The output will be another integer, which gets detokenized back to text.
The tokenization family behind GPT-2 onward, Llama 3, and most modern LLMs. (Llama 1–2 used SentencePiece BPE with byte fallback — same idea.)
"unbelievable"
u n b e l i e v a b l e
[403, 8265, 480]
IDs are illustrative — real splits vary by tokenizer.
"The cat sat on the mat."
Note the leading spaces — whitespace is part of the token.
Tokenizer/API policy and trusted role serialization control reserved tokens. Reservation alone does not prevent prompt injection.
Not all tokens come from text. The vocabulary also reserves control tokens that structure the conversation, mark boundaries, and trigger behaviors.
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi!"},
{"role": "assistant", "content": "Hello!"}
]
<|begin_of_text|> <|start_header_id|>system<|end_header_id|> You are helpful.<|eot_id|> <|start_header_id|>user<|end_header_id|> Hi!<|eot_id|> <|start_header_id|>assistant<|end_header_id|> Hello!<|eot_id|>
[128000, 128006, 9125, 128007, 271, 2675, 527, 11190, 13, 128009, 128006, 882, 128007, 271, 13347, 0, 128009, 128006, 78191, 128007, 271, 9906, 0, 128009]
IDs ≥ 128000 are special. 271 is the "\n\n" that follows every header. The model learns: "after assistant<|end_header_id|>, generate a reply, then emit <|eot_id|> to stop."
System / user / assistant boundaries are just tokens. The model learns to respect them during fine-tuning.
Generation halts when the model emits <|eot_id|> or <|end_of_text|> — that's how the API knows the turn is done.
Function calling, vision inputs, and reasoning modes all get their own special tokens to switch the model's behavior.
Each token ID indexes into a learned table of high-dimensional vectors.
A matrix E ∈ ℝV × d where:
token_id → E[token_id] ∈ ℝ⁴⁰⁹⁶ 403 → [ 0.12, -0.84, 0.31, …, 0.07] 8265 → [-0.45, 0.22, 0.91, …,-0.18] 480 → [ 0.67, -0.13, 0.04, …, 0.55]
Plus a positional encoding is added so the model knows token order.
The residual stream has width d. Attention heads have width d_k, FFN intermediates have width d_ff, and vocabulary logits have width V; not every internal vector has the same width.
| Component | Shape |
|---|---|
| Token embedding table | V × d |
| Position embedding | N_max × d (learned only — RoPE adds none) |
| Q / K / V / O projections | MHA: d×d; GQA/MQA K/V projections can be narrower |
| FFN hidden layer | d × 4d (expand) → 4d × d (project back). Llama's SwiGLU uses 3 matrices of d × ≈8/3·d — same total. |
| LayerNorm | d (γ) + d (β) |
| LM head | d × V |
Hidden state shape stays (B, T, d) from input embedding all the way to the LM head — every block preserves it.
| Model | d | Heads × head_dim |
|---|---|---|
| GPT-2 small (124M) | 768 | 12 × 64 |
| GPT-2 XL (1.5B) | 1,600 | 25 × 64 |
| Llama-2 7B | 4,096 | 32 × 128 |
| Llama-2 70B | 8,192 | 64 × 128 |
| GPT-3 175B | 12,288 | 96 × 128 |
Trade-off: doubling d roughly quadruples compute and memory (most weights are d × d or d × 4d). Bigger d = more capacity but quadratically (not exponentially) more cost per layer. Note d must divide evenly by the number of attention heads.
vec("king") − vec("man") + vec("woman") ≈ vec("queen")
vec("Paris") − vec("France") + vec("Italy") ≈ vec("Rome")
Direction in the space encodes meaning. Distance encodes similarity.
Without positions and without an order-dependent mask, attention is permutation-equivariant. Causal masking changes prefix sets and the applicable symmetry.
Content-only unmasked self-attention is permutation-equivariant: permuting inputs permutes corresponding outputs. It is not invariant, and a causal mask itself changes the permitted symmetry.
Attention weights only depend on token content, not order. So we have to add the order back in explicitly.
x_i = embedding(token_i) + position_vector(i)
└─ what it is ─┘ └─── where it sits ───┘
Same shape as the embedding (ℝᵈ), so addition just works. The transformer now receives both signals fused into one vector.
Fixed sin/cos at exponentially varying frequencies. No parameters. Defined for any length — though quality still drops past the lengths seen in training.
A second embedding table indexed by position (0…N−1). Trained via backprop. Cannot extrapolate past max length.
Rotary Position Embedding — rotates Q and K vectors by an angle proportional to position. Encodes relative distance directly. Extends to longer contexts with scaling tricks (position interpolation, NTK / YaRN).
Treat each position like a token. Allocate a second lookup table P ∈ ℝN_max × d and train it via backprop alongside everything else.
position_id → P[position_id] ∈ ℝᵈ 0 → [ 0.21, -0.74, 0.55, …, -0.08] ← learned 1 → [-0.13, 0.62, 0.41, …, 0.27] ← learned 2 → [ 0.08, -0.31, 0.92, …, -0.44] ← learned … N_max−1 → [ … ]
Each input vector becomes the sum of two learned lookups:
N_max × d parameters (e.g. 2048 × 4096 ≈ 8M).This is why many modern open LLMs (Llama, Mistral, Qwen, GPT-NeoX) moved to RoPE — position encoded as a rotation, which depends only on relative distance and stretches to longer sequences more gracefully.
Every token asks every other token: "how much should I pay attention to you?"
From each token's embedding x, three vectors are projected via learned weight matrices:
Q = x · W_Q ← "what am I looking for?" K = x · W_K ← "what do I offer?" V = x · W_V ← "what info do I carry?"
Attention(Q, K, V) = softmax( Q·Kᵀ / √d ) · V
d = d_k, per-head size)Sentence: "The cat sat on the mat"
When processing "sat", it can only attend to itself and prior tokens.
new_representation = 0.10·V_The + 0.75·V_cat + 0.15·V_sat
(future V_on, V_the, V_mat are excluded)
Real models use dk = 128. Here dk = 2, so every number fits on one slide and you can check it with a calculator.
| token | x | Q = xWQ | K = V = x |
|---|---|---|---|
| The | [1, 0] | [1, 0] | [1, 0] |
| cat | [0, 1] | [0, 2] | [0, 1] |
| sat | [1, 1] | [1, 2] | [1, 1] |
| query | weights on (The, cat, sat) | output |
|---|---|---|
| The | 1.00, —, — | [1.00, 0.00] |
| cat | 0.20, 0.80, — | [0.20, 0.80] |
| sat | 0.14, 0.28, 0.58 | [0.72, 0.86] |
Each row sums to 1. "The" can only see itself. "sat" pulls most from itself and "cat" — its best-matching keys.
1. scores q·k = [1·1+2·0, 1·0+2·1, 1·1+2·1]
= [1, 2, 3]
2. scale ÷ √2 = [0.71, 1.41, 2.12]
3. softmax = [0.14, 0.28, 0.58]
4. blend V 0.14·[1,0] + 0.28·[0,1] + 0.58·[1,1]
= [0.72, 0.86]
import math
Q = [[1, 0], [0, 2], [1, 2]] # The, cat, sat
K = V = [[1, 0], [0, 1], [1, 1]]
def softmax(v):
m = max(v); e = [math.exp(x - m) for x in v]
return [x / sum(e) for x in e]
for t in range(3): # query position
s = [sum(q * k for q, k in zip(Q[t], K[j])) / math.sqrt(2)
for j in range(t + 1)] # causal: keys 0..t only
w = softmax(s)
out = [sum(w[j] * V[j][c] for j in range(t + 1)) for c in range(2)]
print(t, [round(x, 2) for x in w], [round(x, 2) for x in out])
Common mistake: softmax over the wrong axis. Normalize across keys (each row sums to 1), not across queries. Subtracting max(v) first keeps exp from overflowing.
The sqrt(d_k) scaling controls score variance under the stated initialization assumptions and helps prevent saturation. Alternative normalizations and optimization choices exist; training is not mathematically impossible without this exact factor.
Assume each component of Q and K is roughly independent with mean 0 and variance 1. Then for a single dot product:
q · k = Σᵢ qᵢ · kᵢ (i = 1 … d) E[q · k] = 0 Var[q · k] = d Std[q · k] = √d
So as d grows (typical: 64, 128, even 256 per head), the raw scores q·k grow proportionally to √d.
For d=128 → typical scores ≈ ±11 (so 22 is just a 2σ score). For d=4096 → typical scores ≈ ±64.
softmax( Q·Kᵀ / √d ) · V
Softmax is exponential. When inputs are large, one value dominates:
scores = [22, 8, 14, 17] softmax = [0.99, 0.00, 0.00, 0.01]
≈ one-hot. Gradient ≈ 0. Model can't learn.
scores = [1.94, 0.71, 1.24, 1.50] softmax = [0.41, 0.12, 0.20, 0.27]
Soft distribution. Gradients flow. Model learns.
Dot products grow with dimension. Softmax of large numbers is brittle. /√d keeps the input to softmax dimension-independent — so the same architecture works whether you have 64 or 4096 features per head.
LLMs are trained to predict the next token. If a token could see future tokens, training would be trivial — and inference would break.
After computing Q·Kᵀ / √d but before softmax, add a mask matrix M:
scores = Q·Kᵀ / √d scores = scores + M ← M is upper-triangular −∞ attn = softmax(scores) ← e^−∞ = 0, so future is gone output = attn · V
Setting masked entries to −∞ (not 0) means after softmax they become exactly 0 — and the visible probabilities still sum to 1.
Each row = one query token. Each column = a key it might attend to. Green = allowed, red = masked (−∞).
Encoder models (BERT) use bidirectional attention — every token sees every other token. Decoder LLMs (GPT, Llama, Claude) use causal attention — each token sees only the past. That mask — plus the training objective (masked-word filling vs. next-token prediction) — is the main difference between a representation model and a generator.
Multiple heads supply several learned projections/attention distributions. A head can participate in many patterns; one relationship per head is not a representational restriction.
These specializations emerge during training — nobody assigns them.
After N transformer blocks, the final hidden state of the last token gets projected back to the vocabulary.
token logit ───────────────── "on" 8.21 "down" 7.52 "in" 6.87 "upon" 6.42 "quietly" 6.08 … …
token P ───────────────── "on" 0.42 "down" 0.21 "in" 0.11 "upon" 0.07 "quietly" 0.05 … … (sums to 1.0)
Check: 0.42 / 0.21 = 2 = e8.21 − 7.52. Only logit differences matter.
"The cat sat" → "on" "The cat sat on" → "the" "The cat sat on the" → "mat" "The cat sat on the mat"→ "." (stop)
Each generated token is appended to the input and the whole pipeline runs again. That's it. That's an LLM.
Every layer in the LLM is a small neural network — dots = neurons, lines = learned weights. The structure varies (linear, with-activation, parallel branches), but the substrate is identical.
Incremental decoding computes new Q,K,V. Cache persistence, context eviction, summaries, and cross-session storage are serving/application decisions.
Counting weights by hand is the best way to make the architecture concrete. Every number below can be checked with a calculator.
| Piece | Formula | Params |
|---|---|---|
| Token embeddings | V·d | 38,597,376 |
| Position embeddings | Nmax·d | 786,432 |
| Attention (per block) | d·3d + 3d + d·d + d | 2,362,368 |
| FFN (per block) | d·4d + 4d + 4d·d + d | 4,722,432 |
| 2 LayerNorms (per block) | 4d | 3,072 |
| 12 blocks | 12 × 7,087,872 | 85,054,464 |
| Final LayerNorm | 2d | 1,536 |
| LM head | tied to token embeddings | 0 |
| Total | 124,439,808 |
That's the famous "124M". Rule of thumb for the blocks alone: 12·L·d² = 12·12·768² ≈ 85M.
bytes per token = 2 (K and V) × layers × kv_heads
× head_dim × bytes_per_number
| Model (fp16) | Per token | 4,096 tokens |
|---|---|---|
| Llama-2 7B (32 layers, 32 KV heads × 128) | 0.5 MB | 2 GB |
| Llama-2 70B (80 layers, 8 KV heads × 128) | 320 KB | 1.25 GB |
The 70B model caches less per token: it shares K/V across query heads (grouped-query attention, GQA). Multiply by batch size to see why serving is memory-bound.
Blocks: 12·24·1024² ≈ 302M. Embeddings: 50,257·1,024 + 1,024·1,024 ≈ 52.5M. Total ≈ 355M — the published size.
Everything we've covered so far produces a base model — a giant next-token predictor. It knows facts and language but doesn't know how to talk. Three more training stages turn it into ChatGPT / Claude / Llama-Chat.
Data: trillions of tokens from web, books, code, papers.
Objective: predict the next token. Nothing else.
Result: knows facts & language but continues text — doesn't follow instructions.
Prompt: "What is 2+2?" Output: "What is 2+2? A) 3 B) 4 C) 5 Answer key: B Question 2: What is …"
It mimics text it's seen — like a textbook, not a helper.
Data: thousands to millions of curated (instruction, response) pairs — written by humans, and increasingly generated by models and checked by humans.
Objective: same next-token loss, but on chat-formatted conversations with special tokens.
Result: learns the chat format, follows instructions, knows when to stop.
Prompt: "What is 2+2?" Output: "2 + 2 equals 4."
Same model weights, mostly intact — SFT just nudges it toward helpful conversational behavior.
Data: humans rank pairs of model responses ("A is better than B").
Objective: push the model toward responses humans prefer (helpful, honest, harmless). RLHF: reward model + RL. DPO: learns straight from the pairs.
Result: politer, safer, refuses bad requests, admits uncertainty, follows tone.
Prompt: "How do I hack my
neighbor's wifi?"
Output: "I can't help with
unauthorized access. If it's
your own network, here's
how to recover credentials…"
Post-training can alter style, instructions, reasoning, and factual behavior. It is not restricted to tone, and it does not guarantee retention or improvement of knowledge.
For classic chat models, the vast majority of compute went into pre-training (the architecture from this whole talk). Newer "reasoning" models spend a growing share on reinforcement learning after SFT. Either way, the later stages are what makes the difference between a "fancy autocomplete" and something you can actually have a conversation with.
Incremental decoding computes new Q,K,V. Cache persistence, context eviction, summaries, and cross-session storage are serving/application decisions.
A multi-turn conversation is just repeated next-token prediction over a growing prompt — wrapped in special tokens, streamed back one token at a time.
A stateless transformer function consumes a supplied context. The application or serving runtime may persist conversation state, summaries, or reusable KV prefixes across turns.
turn 1 prompt: <sys>… <user>Hi</> <asst> turn 2 prompt: <sys>… <user>Hi</> <asst>Hello!</> <user>How are you?</> <asst> turn 3 prompt: <sys>… <user>Hi</> <asst>Hello!</> <user>How are you?</> <asst>Doing well!</> <user>What did I say first?</> <asst>
This is why long conversations get expensive — the prompt grows unboundedly until it hits the context window limit.
Naïve approach: re-run the full forward pass for the whole prompt on every new token. Cost: O(N²) per token, O(N³) per response.
Incremental decoding computes new Q, K, and V for the new position and reuses cached past K/V. Context length still contributes attention work.
per-token cost: without KV cache → O(N²) with KV cache → O(N)
The KV cache is why first token is slow ("prefill") but subsequent tokens are fast ("decode"). It also dominates GPU memory in long conversations.
Incremental decoding computes new Q,K,V. Cache persistence, context eviction, summaries, and cross-session storage are serving/application decisions.
A chatbot answers in one shot. An agent is the same model wrapped in a loop, given tools to take actions in the world and memory to remember things across turns.
Tools are added declaratively in the system prompt as JSON schemas. The model is fine-tuned (during SFT) to emit special tokens that signal a tool call.
<|sys|>You are a helpful assistant.<|/> <|user|>What's the weather in SF?<|/> <|asst|>
<|sys|>You are a helpful assistant.
You have access to the following tools:
[
{
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"city": {"type": "string", "required": true}
}
},
{ "name": "search_web", … },
{ "name": "send_email", … }
]<|/>
<|user|>What's the weather in SF?<|/>
<|asst|>
<|tool_call|>
{"name": "get_weather", "args": {"city": "SF"}}
<|/tool_call|>
<|tool_result|>
{"temp_f": 62, "conditions": "foggy"}
<|/tool_result|>
<|asst|>It's 62°F and foggy in SF right now.<|/>
The model never actually called an API — it just emitted text. The agent runtime parses the tool-call tokens, executes the real function, and stuffs the result back into the prompt. Same loop the chatbot uses, just with extra structured turns.
The model itself is stateless — it forgets everything between API calls. "Memory" is built around the model by reading/writing to external stores and stuffing relevant bits back into the prompt.
Retained conversation context is bounded and incurs processing/storage cost; the runtime decides what to retain, resend, cache, or summarize.
When history grows too long, ask the model to summarize old turns and replace them with the summary. Lossy but cheap.
Embed past conversations (or external docs) into vectors. At each turn, retrieve the top-k most similar chunks and inject them. Scales to millions of facts.
Give the agent read_memory / write_memory tools. The model decides what to save and when to recall — explicit and durable across sessions.
<|sys|>You are a helpful assistant. ## Recalled memory (relevant facts): - User prefers metric units - User is based in Berlin - Last conversation: discussed Python tooling [tool definitions…]<|/> <|user|>What's the weather?<|/>
Many transformer applications serialize retrieved memory into context text. Other designs use structured inputs, cached vectors, multimodal representations, or recurrent state; memory is not universally extra text.
Tools and memory don't change the model. They change what's in the prompt at each step. An "agent framework" (LangChain, AutoGPT, Claude's tool use API, etc.) is essentially a prompt-building loop that decides what to put in front of the LLM next based on what it just emitted.
Ideas students (and engineers) often get wrong about LLMs — each one ties back to a slide in this talk.
"The model reads words."
It reads token IDs. That's why it can struggle to count letters in "strawberry" — it never sees the letters.
"It remembers our last chat."
The model is stateless. The app re-sends the history, or injects saved memory into the prompt.
"Temperature 0 means it's sure."
T = 0 just means greedy: always take the top token. The top token can still have P = 0.2.
"Mask future tokens with 0."
Use −∞ before softmax. A 0 score still gets weight e0 = 1, so the future leaks in.
"Attention weights explain the answer."
They show where information moved in one layer, not why. Many heads and layers mix it again.
"A longer context window is free."
The KV cache grows linearly and prompt processing (prefill) grows quadratically with length.
Fine-tuning can change factual and task capabilities as well as behavior. Retrieval supplies additional evidence; it does not replace the need to evaluate factual accuracy.
"The model calls the API."
It only emits text that describes a call. The runtime executes it and pastes the result back.
Try each one before you open the answer.
BPE merges the most frequent pairs first. "the" is extremely common, so it became one token early. Rare words are built from reusable pieces.
Without positional signals and without order-dependent masking, input permutations permute outputs. With causal masking, the allowed prefix sets change, so that simplified statement does not apply unchanged.
Scale by √4 = 2 → [1, 0, 0]. Softmax → e/(e+2) ≈ 0.58, then 0.21, 0.21.
4,096 / 32 = 128. The heads split d evenly; after attention their outputs are concatenated back to 4,096.
T = 1: 1/(1+e−2) ≈ 0.88. T = 2: logits become 1.5 and 0.5, so P(A) = 1/(1+e−1) ≈ 0.73. Higher T flattens the distribution.
The first token needs a full pass over the whole prompt (prefill). After that, K and V are cached, so each new token only runs one position through the model (decode).
7 × 109 parameters × 2 bytes ≈ 14 GB, before any KV cache or activations.
More training: SFT on chat-formatted examples (with special tokens), then preference tuning (RLHF or DPO). The architecture is unchanged.
| Token | A chunk of text (word, piece of a word, byte) with an integer ID. |
| BPE | Byte Pair Encoding — builds a vocabulary by merging frequent pairs. |
| Embedding | The learned vector for a token; a row of the table E. |
| d (d_model) | Width of every hidden vector in the model. |
| d_k (head dim) | Width of each head's Q and K vectors; d / heads. |
| RoPE | Rotary position embedding — rotates Q and K by position. |
| Q, K, V | Query, key, value projections used by attention. |
| Causal mask | −∞ on future positions so a token sees only the past. |
| Multi-head | Several attention heads in parallel, then concatenated. |
| FFN / MLP | The per-token feed-forward network inside each block. |
| Logits | Raw scores over the vocabulary, before softmax. |
| Temperature | Divides logits; lower = sharper, higher = more random. |
| Top-k / top-p | Sample only from the k best, or the smallest set with mass ≥ p. |
| KV cache | Stored K and V of past tokens so decode skips recomputation. |
| Prefill / decode | Processing the prompt at once / generating one token at a time. |
| Context window | Max tokens the model can attend to in one call. |
| SFT | Supervised fine-tuning on (instruction, response) examples. |
| RLHF / DPO | Tuning on human preferences, with or without a reward model. |
| Agent | An LLM in a loop that can call tools and use memory. |
Questions?
Can a small model learn next-character prediction while being unable to see future targets?
The file contains its own imports, data generator or fixture, implementation, and checks.
python3 -m venv .venv source .venv/bin/activate python -m pip install torch python llm_tech_talk.py python llm_tech_talk.py --self-test
Use Python 3.10+. On Windows, activate with .venv\Scripts\Activate.ps1. Required packages: torch>=2.2.
Read the contract first. Identify the inputs, intermediate state, and return value in this excerpt from the verified file.
def forward(self, tokens):
b, t = tokens.shape
if t > self.context:
raise ValueError("sequence exceeds allocated position embeddings")
x = self.token(tokens) + self.position(torch.arange(t, device=tokens.device))
q, k, v = self.qkv(self.norm1(x)).chunk(3, dim=-1)
q, k, v = [value.reshape(b, t, self.heads, self.width//self.heads).transpose(1, 2)
for value in (q, k, v)]
scores = q @ k.transpose(-2, -1) / math.sqrt(self.width//self.heads)
mask = torch.ones(t, t, dtype=torch.bool, device=tokens.device).triu(1)
attention = scores.masked_fill(mask, -torch.inf).softmax(-1)
mixed = (attention @ v).transpose(1, 2).contiguous().reshape(b, t, self.width)
x = x + self.output(mixed)
x = x + self.ffn(self.norm2(x))
return self.head(x)Vocabulary: '\n .acdeghnorst' Initial/final next-character loss: 2.84539532661438 0.053476203233003616 Generated: 'the dog ran.\nthe cat sat.\nthe dog ran.\nthe c' The small corpus demonstrates teacher forcing and causality; it does not establish general language ability.
Output was captured from the complete program running in an isolated temporary directory. Floating-point digits and compressed sizes may vary across versions/hardware.
5 checks passed, including boundary cases and independent reference comparisons where applicable. These executed checks complement the reasoning; they are not a universal proof.
Remove the future mask and rerun the causality check. Why can training loss improve while the generator becomes invalid?
Without masking, positions can read the answer token during training. Such information does not exist during left-to-right generation, so low training loss is misleading.