What does attention do inside an AI model?

Attention lets each word of a text pick up what earlier words mean: it scores them, turns the scores into weights, and takes a weighted average.

  • 6 min read
  • 18 min to go deeper
On this page
  1. In one minute
  2. Explore the numbers
  3. How it works
  4. What this means when you build
  5. Go deeperoptional
  6. Skills this lesson builds
  7. Check yourself
  8. Further reading
  9. Codeoptional

Read “The cat sat because it was tired.” You know “it” means the cat. A language model, the kind of program behind a chatbot, has to work that out too, and attention is the step that lets a word pick that up from the words before it. In a tiny model with numbers picked by hand, the word “it” puts 68% of its weight (its share of attention) on “cat”, because “cat” is the word whose label best answers what “it” is looking for.

In one minute

Attention is the step where each token, a small piece of text and often a whole word, collects information from the tokens before it. Picture a room where every earlier word holds up a label and carries a note. The word that is reading asks a question, compares it with every label, and takes a blend of the notes, leaning toward the labels that answer it best. The question is called a query, the label a key, and the note a value. “It” asks “what do I point to?”, the label on “cat” says “an animal”, so “cat” gets the most weight, and “it” leaves with a note that is mostly “animal”.

Explore the numbers

Open the widget. It opens on “it” and shows the weight, or share of attention, “it” gives each earlier word. Tap another word to see its weights, then change the kind of look and watch the bars move. Words after the chosen one are not looked at. Every number is picked by hand to show the mechanism; a real model learns its numbers.

How it works

Looking back at every word

Why do answers appear word by word? uses a toy that looks back only a few words. A real model looks back at every earlier token, and attention decides which ones matter for the word being read. In the toy here, each word is six numbers: four say what the word is like (an animal, pointing to something, an action or state, a small function word), and two give its place in the sentence.

Query, key and value

A head is one way of looking. It turns each word’s six numbers into three short lists, by multiplying and adding with fixed tables of numbers. The query is what the word looks for, the key is the label it holds up, and the value is the note it carries. In the head called “Refers to”, the query of “it” is its “pointing to something” number times 3, and the key of “cat” is its “an animal” number. Every other word has a key of 0.

Scores, then weights

For each earlier word, the score is the query times the key (multiply matching numbers, then add), divided by the square root of how many numbers the query has. For “it” and “cat” that is 3 × 1 ÷ √2 = 2.12. For every other word it is 0. A word looks only at itself and the words before it, so “was” and “tired” get a score of −∞ and are hidden. That hiding is called a causal mask. Softmax then turns the scores into weights that are positive and add up to 1; lesson 21 explains it. These attention weights are not the learned numbers inside a model, which share the name. “Cat” gets 68% and each other visible word 8%.

For the word it, a table of the five words up to it with scores and weights. Cat has score 2.12 and weight 68 percent, the longest bar. The others have score 0 and weight 8 percent. Was and tired are not looked at.

Figure 1. The weights of “it” in the head that looks for what a word points to. Green marks the biggest weight.

The blend

The word then takes the weighted average of the values: 68% of the note on “cat”, 8% of each other note. Before, “it” held 0 on “an animal”. After, it holds 0.68. It has picked up what it points to, and nothing told it so.

Every word at once

Every word does the same at once. The grey half of Figure 2 is the mask. The first word, “The”, has only itself to look at, so it puts 100% there.

A seven by seven grid of percent weights. Each row is a word looking back at the words in its row. The upper right half is grey. In the row for it, cat has 68 percent.

Figure 2. Every word’s weights over the words before it. Green outlines the biggest weight in each row.

Several heads

One head finds one kind of link. A model runs many heads side by side, each with its own query table (key and value tables can be shared by several heads, as in grouped-query attention), and joins what they return. Figure 3 adds a head that looks for the word just before. Under that head, “it” puts 71% on “because”, and the joined result carries both links.

For the word it, two sets of bars over the five words up to it. One head puts 68 percent on cat. The other puts 71 percent on because, the word just before.

Figure 3. Two heads read the same word. Green, “Refers to”: what the word points to. Orange, “Word before”: the word just before.

What the toy leaves out

The toy’s numbers are chosen, not trained. A real model learns its numbers from data, has thousands per token, and stacks many layers, each with many heads, with more steps after every attention step. Its position information also differs. Attention alone does not know the order of words, so a model is given the place of each word; one published way adds sine and cosine waves.

What this means when you build

  • Cost grows with the square. Each token scores itself and the tokens before it, so n tokens make about n²/2 scores per head per layer: 500,500 for 1,000 tokens, 32,004,000 for 8,000. Code that builds the whole n by n table and then hides the later half does about twice that work; fast kernels such as FlashAttention-2 skip the blocks that are fully hidden. Lesson 21 shows how real code avoids storing the scores, and lesson 23 what long prompts cost before the first word.
  • Servers keep keys and values. A new token needs every earlier token’s key and value, so a server saves them instead of recomputing: the attention cache. Lesson 22 shows how it is stored, and lesson 3 its size.
  • Read weight pictures as hints. A big weight shows which note a head blended in, not why the model answered as it did. Many heads and layers follow.
  • You rarely write it yourself. Libraries ship this step, and lesson 21 names where. Writing it once, as in Go deeper, is how you learn what to check.

Optionalabout 18 min

Go deeper

Optional. This part is for readers who write Python. The code is in the code/ folder next to this lesson. From inside that folder, python3 show_numbers.py prints the numbers below, and python3 -B -m pytest -q -p no:cacheprovider runs the tests.

The formula

Put the queries of all words in a table Q with one row per word, and the keys and values in K and V. Vaswani et al. write scaled dot-product attention as softmax(QKᵀ / √d_k) V, where d_k is the length of a query and of a key. For a model that writes text, a word must not look ahead, so the scores of later words are set to −∞ before the softmax, which gives them weight 0. The paper does this in the decoder’s self-attention. In steps:

scores  = Q K^T / sqrt(d_k)     row i, column j: how well word j's key answers word i's query
scores  = -inf where j > i      the causal mask
weights = softmax of each row   positive, adds up to 1
output  = weights V             row i: the weighted average of the values

The toy’s numbers for “it”

In the head “Refers to”, the query of “it” is [3, 0], and the only key that is not [0, 0] belongs to “cat”, [1, 0]. The length of a query is d_k = 2, so each score is divided by √2 = 1.4142.

Word Score Weight
The 0.000 0.0810
cat 2.121 0.6759
sat 0.000 0.0810
because 0.000 0.0810
it 0.000 0.0810
was −∞ 0
tired −∞ 0

Softmax turns each score into a size, e to the power of the score, and divides by the total. The size for “cat” is e^2.121 = 8.34, and each other visible word has size 1. The total is 12.34, so “cat” gets 8.34 ÷ 12.34 = 0.6759 and each other word 1 ÷ 12.34 = 0.0810. The values are four numbers per word: how much it is an animal, points to something, is an action or state, is a small function word. The weighted average is:

0.6759 x [1, 0, 0, 0]                                  (cat)
+ 0.0810 x ([0, 0, 0, 1] + [0, 0.6, 1, 0]
           + [0, 0, 0, 1] + [0, 1, 0, 0])              (The, sat, because, it)
= [0.676, 0.130, 0.081, 0.162]

The first number, 0.676, is how much “an animal” the blend carries. The word “it” alone had 0.

Other words use the same head and the same keys, but their queries differ, because the query is 3 times the word’s own “pointing to something” number. For “sat” that number is 0.6, so the score on “cat” is 0.6 × 3 ÷ 1.4142 = 1.273, its size is 3.57, and with two other visible words of size 1 its weight is 3.57 ÷ 5.57 = 0.64. For “tired” it is 0.8, the score is 1.697, the size is 5.46, and with six other words the weight is 5.46 ÷ 11.46 = 0.48. For “because” the number is 0, every score is 0, and its four weights are 0.25 each. So the weight on “cat” depends on the word’s own query and on how many words compete with it.

The code

These are the real functions from code/attention.py with their comments left out. Plain Python lists, no libraries:

def scores_for(q, keys, i, scale=True):
    divisor = math.sqrt(len(q)) if scale else 1.0
    return [dot(q, keys[j]) / divisor if j <= i else -math.inf for j in range(len(keys))]

def softmax(scores):
    top = max(scores)
    sizes = [math.exp(s - top) for s in scores]
    total = sum(sizes)
    return [size / total for size in sizes]

def weighted_average(weights, values):
    return [sum(w * v[c] for w, v in zip(weights, values)) for c in range(len(values[0]))]

def attend(vectors, head, scale=True):
    queries = [times(x, head["wq"]) for x in vectors]
    keys = [times(x, head["wk"]) for x in vectors]
    values = [times(x, head["wv"]) for x in vectors]
    scores = [scores_for(queries[i], keys, i, scale) for i in range(len(vectors))]
    weights = [softmax(row) for row in scores]
    outputs = [weighted_average(w, values) for w in weights]
    return {"queries": queries, "keys": keys, "values": values,
            "scores": scores, "weights": weights, "outputs": outputs}

Subtracting the biggest score inside softmax changes no weight and avoids overflow, as lesson 21 shows; math.exp(-inf) is 0, which is how the mask works. The three tables of each head are wq, wk and wv. They are the toy’s chosen numbers, and a real model learns them.

Why divide by the square root

Vaswani et al. suspect that for large d_k the dot products grow large, which pushes the softmax into regions where it has very small gradients, so they divide by √d_k. If the numbers of a query and a key are independent with mean 0 and variance 1, a dot product has mean 0 and variance d_k. The paper also reports, citing another study, that without the division dot-product attention does worse than another scoring method, additive attention, at larger d_k.

scaling_experiment checks the first part. It draws random queries and keys with numbers of mean 0 and spread 1 (seed 6), scores one query against 8 keys, and repeats 100 times at three lengths. Figure 4 shows how far the scores spread. Without the division the spread is 2.16, 8.11 and 31.56, close to √d = 2, 8 and 32. With it, the spread is 1.08, 1.01 and 0.99. The table shows the biggest weight in a row of 8, on average. Plain is without the division, Divided is with it.

Bars of the spread of scores at d equal 4, 64 and 1,024. Without dividing: 2.2, 8.1 and 31.6. Divided by the square root of d: 1.1, 1.0 and 1.0.

Figure 4. The spread of 800 scores at each length d, without and with the division. Orange grows with √d. Green stays near 1.

d Plain Divided
4 0.53 0.34
64 0.90 0.36
1,024 0.96 0.36

At d = 1,024 the biggest score takes 96% of the weight without the division, so softmax almost ignores the other seven words. With it, the biggest takes 36%, the same as at d = 64. This tests only random inputs; it shows the spread, not what happens in training.

Two heads, joined

multi_head runs each head and writes each word’s outputs side by side. For “it”, the first head gives [0.676, 0.130, 0.081, 0.162] and the second, the word just before, gives [0.004, 0.231, 0.145, 0.707], so the joined list has eight numbers. In the paper’s base model there are 8 heads of 64 numbers each; the paper joins them and applies one more table of learned numbers, which the toy leaves out. The paper’s stated reason is that several heads let the model look at different kinds of information at once. The toy’s second head builds its query by turning the word’s place back one step, with strength 6, so the key of the previous word matches best.

What the code costs

A word scores itself and each word before it, so word 1 makes 1 score, word 2 makes 2, and word n makes n. A prompt of n words makes n(n+1)/2 scores in one head.

Words Scores per head
10 55
1,000 500,500
8,000 32,004,000

Lesson 23 uses a model with 32 layers of 32 query heads (they share 8 key-value heads, see lesson 22), which is 1,024 query heads in all. Across them, 10 words make 56,320 scores, 1,000 words make 512,512,000, and 8,000 words make 32,772,096,000. Doubling the words from 1,000 to 2,000 takes the count from 500,500 to 2,001,000, a little under four times. This counts scores only, not the multiplications that make each score or the work around attention. Lesson 21 shows how FlashAttention avoids storing the table, and lesson 22 how big the attention cache gets.

What the toy leaves out, in detail

The six numbers of each word are chosen: four about the word and two for its place. The place numbers are the cosine and sine of an angle of 0.9 radians per step. Vaswani et al. add sine and cosine position signals to the input embeddings, the learned vector for each token, so the toy’s version is a simpler cousin and not the paper’s. The toy has one layer, no learned tables, no table to join heads, and none of the steps that follow attention in a real layer.

Proof it works

The independent check is reference in test_attention.py. It follows the formula with NumPy matrix operations, with x the table of word vectors and wq, wk, wv a head’s three tables:

q, k, v = x @ wq, x @ wk, x @ wv
s = q @ k.T / math.sqrt(k.shape[1])
s = s + np.triu(np.full(s.shape, -np.inf), k=1)
e = np.exp(s - s.max(axis=1, keepdims=True))
w = e / e.sum(axis=1, keepdims=True)
output = w @ v

np.triu(..., k=1) is the mask: it puts −∞ above the diagonal and 0 elsewhere. The loops in attention.py and these matrix lines share no code, so a slip in one shows up as a difference from the other.

test_attention.py pins the numbers above. The weights of “it” must match the hand-worked 0.0810 and 0.6759 and the sentence “it” puts 68% of its weight on “cat”. For every head, with and without the division, weights must be positive and add up to 1, and rewriting any later word must leave every earlier row unchanged, so no word looks ahead. An independent version in NumPy matrix operations, with np.triu for the mask, must agree with the Python to 1e-12 on the toy and on seeded random inputs. The scaling experiment must print the numbers in the text. The score count is checked against a brute-force count and the layer and head counts against lesson 23’s code. test_widget.py runs the widget’s JavaScript in Node against the Python for every head and every word, and drives the page through a small stand-in for the browser.

Where this is used in practice

Vaswani, Shazeer, Parmar, Uszkoreit, Jones, Gomez, Kaiser and Polosukhin (2017) built a model from attention alone, with no recurrence or convolution. Its section 3.2 gives the scaled dot-product attention, the division by √d_k and the multiple heads described above, and it uses the mask to keep a decoder from looking ahead. Bahdanau, Cho and Bengio (ICLR 2015) had earlier used a related idea for translation. Instead of squeezing a source sentence into one fixed-length vector, their model learned to look back at parts of the source for each word it wrote. Their abstract reports English-to-French results about as good as the best phrase-based system of the time, and says the learned alignments agree with intuition. Both were read on 2026-10-19: the first in full, the second its abstract only. Neither claims the toy’s numbers, which are made for this lesson.

Skills this lesson builds

  • Explain attention as a query matched against keys, then a weighted average of values.
  • Compute a word’s weights by hand from its scores: divide by the square root, mask, softmax.
  • Write scaled dot-product attention with a causal mask in plain Python and check it against NumPy.
  • Say why scores are divided by √d, and show it with random vectors.
  • Count the scores a prompt makes and say how that grows with length.

Portfolio project: pick a sentence of 6 to 8 words that has a pronoun in it. Choose a small vector for each word and one head whose query and key make the pronoun look at the right word. Write the attention, test it against NumPy, and publish the weight grid as a picture. Add a note on what happens to the weights when you remove the mask or the division.

Check yourself

  1. In your own words, what do the query, the key and the value of a word do?

    Answer

    The query is what the word looks for. Each earlier word’s key is a label that is compared with the query to give a score. The value is the note a word hands over. The scores become weights, and the word takes the weighted average of the values.

  2. In the toy, “it” scores 2.12 on “cat” and 0 on four other words. Why does “cat” get 68% and not nearly 100%?

    Answer

    The weight is the size of a score over the total of all sizes. The size for “cat” is e^2.121 = 8.34 and the others have size 1 each, so the total is 12.34 and “cat” gets 8.34 ÷ 12.34 = 0.676. A score of 2.12 is a clear lead but not a huge one.

  3. Why do “was” and “tired” get weight 0 in the row for “it”?

    Answer

    They come after “it”. The causal mask sets their scores to −∞ before the softmax, and e to the power of −∞ is 0, so a word never looks at a later one.

  4. A prompt grows from 1,000 to 2,000 tokens. About how many more scores does one head make?

    Answer

    One head makes 500,500 scores for 1,000 tokens and 2,001,000 for 2,000. That is 1,500,500 more, a little under four times as many.

  5. With the division removed, what happens to the weights as d grows, and why?

    Answer

    The scores spread out in proportion to √d: the spread is 2.16 at d = 4 and 31.56 at d = 1,024. The biggest score then takes almost all the weight (96% at d = 1,024), so the other words are ignored. Dividing by √d keeps the spread near 1 and the biggest weight near 36% for 8 words.

Further reading

Gentle

Deep

Optional

Code

Each file runs on its own. Download it, or read it here.

attention.py204 linesDownload attention.py
Show code
"""Attention on one short sentence, step by step, in plain Python.

The sentence is "The cat sat because it was tired". Each word gets six numbers, and each head gets three small maps (query, key
and value). Every number is picked by hand so that the output is easy to read: nothing here was trained, and a real model learns
its numbers from data and uses thousands per word. The five steps below are the scaled dot-product attention of Vaswani et al.,
"Attention Is All You Need" (arXiv 1706.03762, section 3.2): for each word, score every word before it, hide the words after
it, turn the scores into weights that add up to 1, and take the weighted average of the earlier words' values.

    scores   = q . k / sqrt(d)            one per earlier word, d = how many numbers a query has
    weights  = softmax(scores)            positive, and they add up to 1
    output   = sum of weight * value      a blend of the earlier words' values

Python 3 standard library only. The widget (../widget.html) repeats these steps in JavaScript; test_widget.py runs both and
compares them, and test_attention.py checks this file against NumPy matrix operations.
"""
from __future__ import annotations

import math
import random

# --- the sentence, and six numbers per word: four about the word, then its place in the sentence (the cosine and sine of an angle) ---
TOKENS = ["The", "cat", "sat", "because", "it", "was", "tired"]
ABOUT = ["an animal", "pointing to something", "an action or state", "a small function word"]
CONTENT = [
    [0.0, 0.0, 0.0, 1.0],   # The
    [1.0, 0.0, 0.0, 0.0],   # cat      the only animal
    [0.0, 0.6, 1.0, 0.0],   # sat      an action that needs someone to do it
    [0.0, 0.0, 0.0, 1.0],   # because
    [0.0, 1.0, 0.0, 0.0],   # it       points to something, and carries nothing about what
    [0.0, 0.4, 1.0, 0.0],   # was
    [0.0, 0.8, 1.0, 0.0],   # tired    describes something
]
ANGLE = 0.9                  # radians between neighbouring positions


def position(p: int) -> list[float]:
    return [round(math.cos(ANGLE * p), 2), round(math.sin(ANGLE * p), 2)]


VECTORS = [CONTENT[p] + position(p) for p in range(len(TOKENS))]
D_MODEL = len(VECTORS[0])    # numbers per word: 6
D_K = 2                      # numbers per query and per key: 2
D_V = 4                      # numbers per value: 4


# --- three maps per head. A map is a table with D_MODEL rows and as many columns as the vector it makes. ---
def zeros(rows: int, cols: int) -> list[list[float]]:
    return [[0.0] * cols for _ in range(rows)]


def identity_on_content() -> list[list[float]]:
    value = zeros(D_MODEL, D_V)                  # the value keeps the four numbers about the word and drops the position
    for i in range(D_V):
        value[i][i] = 1.0
    return value


def refers_head(strength: float = 3.0) -> dict:
    """Looks for what a word points to: a word that points to something (column 1 of CONTENT) asks for an animal (column 0)."""
    query, key = zeros(D_MODEL, D_K), zeros(D_MODEL, D_K)
    query[1][0] = strength                       # "pointing to something" becomes the first number of the query
    key[0][0] = 1.0                              # "an animal" becomes the first number of the key
    return {"name": "refers", "wq": query, "wk": key, "wv": identity_on_content()}


def before_head(strength: float = 6.0) -> dict:
    """Looks for the word just before: the query is this word's position turned back by one step, so it matches the key of the word at p - 1."""
    c, s = round(strength * math.cos(ANGLE), 2), round(strength * math.sin(ANGLE), 2)
    query, key = zeros(D_MODEL, D_K), zeros(D_MODEL, D_K)
    query[4][0], query[4][1] = c, -s             # rows 4 and 5 are the position numbers
    query[5][0], query[5][1] = s, c
    key[4][0], key[5][1] = 1.0, 1.0              # the key is the position itself
    return {"name": "before", "wq": query, "wk": key, "wv": identity_on_content()}


def even_head() -> dict:
    """Asks for nothing: every query is all zeros, so every earlier word scores 0 and the weights come out equal."""
    return {"name": "even", "wq": zeros(D_MODEL, D_K), "wk": refers_head()["wk"], "wv": identity_on_content()}


HEADS = {"refers": refers_head(), "before": before_head(), "even": even_head()}
HEAD_LABELS = {"refers": "Refers to", "before": "Word before", "even": "No preference"}


# --- the five steps ---
def times(vector: list[float], table: list[list[float]]) -> list[float]:
    """A vector times a map: one output number per column."""
    return [sum(vector[r] * table[r][c] for r in range(len(vector))) for c in range(len(table[0]))]


def dot(a: list[float], b: list[float]) -> float:
    return sum(x * y for x, y in zip(a, b))


def scores_for(q: list[float], keys: list[list[float]], i: int, scale: bool = True) -> list[float]:
    """Step 2 and 3: q . k / sqrt(d) for every word, with -inf for the words after word i (the causal mask)."""
    divisor = math.sqrt(len(q)) if scale else 1.0
    return [dot(q, keys[j]) / divisor if j <= i else -math.inf for j in range(len(keys))]


def softmax(scores: list[float]) -> list[float]:
    """Step 4: positive weights that add up to 1. Subtracting the biggest score first changes nothing and avoids overflow; exp(-inf) is 0."""
    top = max(scores)
    sizes = [math.exp(s - top) for s in scores]
    total = sum(sizes)
    return [size / total for size in sizes]


def weighted_average(weights: list[float], values: list[list[float]]) -> list[float]:
    """Step 5: each number of the output is the weights times that number of every value, added up."""
    return [sum(w * v[c] for w, v in zip(weights, values)) for c in range(len(values[0]))]


def attend(vectors: list[list[float]], head: dict, scale: bool = True) -> dict:
    """Run one head over a sentence. Returns the queries, keys, values, scores, weights and outputs, one row per word."""
    queries = [times(x, head["wq"]) for x in vectors]       # step 1: what each word looks for ...
    keys = [times(x, head["wk"]) for x in vectors]          # ... the label each word holds up ...
    values = [times(x, head["wv"]) for x in vectors]        # ... and the note it carries
    scores = [scores_for(queries[i], keys, i, scale) for i in range(len(vectors))]
    weights = [softmax(row) for row in scores]
    outputs = [weighted_average(w, values) for w in weights]
    return {"queries": queries, "keys": keys, "values": values, "scores": scores, "weights": weights, "outputs": outputs}


def multi_head(vectors: list[list[float]], heads: list[dict]) -> list[list[float]]:
    """Several heads side by side: each word's outputs from every head, joined into one longer list."""
    runs = [attend(vectors, head)["outputs"] for head in heads]
    return [sum((run[i] for run in runs), []) for i in range(len(vectors))]


# --- what the page shows ---
def sentence(head: str, i: int) -> str:
    """The one line under the bars: where word i puts its weight."""
    w = attend(VECTORS, HEADS[head])["weights"][i]
    token = TOKENS[i]
    looked = [j for j in range(i + 1) if w[j] > 0]
    if len(looked) > 1 and max(w[:i + 1]) - min(w[:i + 1]) < 1e-9:
        return f"“{token}” spreads its weight evenly: {100 * w[0]:.0f}% on each of {len(looked)} words."
    top = max(range(len(w)), key=lambda j: w[j])
    return f"“{token}” puts {100 * w[top]:.0f}% of its weight on “{TOKENS[top]}”."


def blend_line(head: str, i: int) -> str:
    """The line about what the blend carries: the first number of a word's output says how much "an animal" it holds."""
    out = attend(VECTORS, HEADS[head])["outputs"][i]
    return f"Blended note, “an animal”: {out[0]:.2f}. “{TOKENS[i]}” alone: {CONTENT[i][0]:.2f}."


def percent(w: float) -> str:
    """A weight as a whole percent; a weight that is tiny but not zero shows as <1 so it never reads as zero."""
    return "<1" if 0 < w < 0.005 else f"{100 * w:.0f}"


def view(head: str, i: int) -> dict:
    """Everything the page writes for one head and one selected word. A word after word i is not looked at, so its cells are empty."""
    run = attend(VECTORS, HEADS[head])
    return {
        "weights": [percent(w) if j <= i else "" for j, w in enumerate(run["weights"][i])],
        "scores": [f"{s:.2f}" if j <= i else "" for j, s in enumerate(run["scores"][i])],
        "sentence": sentence(head, i), "blend": blend_line(head, i),
        "grid": [[percent(w) if j <= r else "" for j, w in enumerate(row)] for r, row in enumerate(run["weights"])],
    }


# --- why the division by sqrt(d) ---
def scaling_experiment(lengths=(4, 64, 1024), words=8, trials=100, seed=6) -> list[dict]:
    """Random queries and keys with numbers of mean 0 and spread 1, the assumption in the footnote in section 3.2.1 of the paper. For each
    length d, score one query against `words` keys, with and without the division, and report how spread out the scores are (their
    standard deviation) and how much weight the biggest score gets (the average over trials)."""
    rng = random.Random(seed)
    rows = []
    for d in lengths:
        raw, scaled, top_raw, top_scaled = [], [], [], []
        for _ in range(trials):
            q = [rng.gauss(0, 1) for _ in range(d)]
            s = [dot(q, [rng.gauss(0, 1) for _ in range(d)]) for _ in range(words)]
            raw += s
            scaled += [x / math.sqrt(d) for x in s]
            top_raw.append(max(softmax(s)))
            top_scaled.append(max(softmax([x / math.sqrt(d) for x in s])))
        rows.append({"d": d, "spread_raw": spread(raw), "spread_scaled": spread(scaled),
                     "top_raw": sum(top_raw) / trials, "top_scaled": sum(top_scaled) / trials})
    return rows


def spread(numbers: list[float]) -> float:
    mean = sum(numbers) / len(numbers)
    return math.sqrt(sum((x - mean) ** 2 for x in numbers) / len(numbers))


# --- what attention costs ---
LAYERS = 32     # copied from lesson 23 (prefill_sim.py): Llama 3.1 8B has 32 layers and 32 query heads per layer (they share 8 key-value heads)
HEADS_PER_LAYER = 32


def score_count(n: int) -> int:
    """Scores one head makes over a prompt of n words: word 1 scores 1 word, word 2 scores 2, ... word n scores n."""
    return n * (n + 1) // 2


def config() -> dict:
    """What the page needs to run the same steps: the words, the numbers per word, the maps of each head and the labels."""
    return {"tokens": TOKENS, "about": ABOUT, "vectors": VECTORS, "heads": HEADS, "labels": HEAD_LABELS,
            "opening": {"head": "refers", "token": TOKENS.index("it")}}
embed_widget.py29 linesDownload embed_widget.py
Show code
"""Keep the copy of the toy's numbers inside ../widget.html equal to the ones in attention.py.

    python3 embed_widget.py          rewrite the data block in widget.html
    python3 embed_widget.py --check  exit 1 if widget.html differs (test_widget.py runs the same check)
"""
import json
import re
import sys
from pathlib import Path

import attention as att

WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CONFIG_BLOCK = re.compile(r'(<script type="application/json" id="attn-config">)(.*?)(</script>)', re.S)


def embed(html):
    """html with the data block rewritten from attention.py."""
    settings = json.dumps(att.config(), ensure_ascii=False, separators=(", ", ": "))
    return CONFIG_BLOCK.sub(lambda m: m.group(1) + "\n" + settings + "\n" + m.group(3), html, count=1)


if __name__ == "__main__":
    current = WIDGET.read_text(encoding="utf-8")
    updated = embed(current)
    if "--check" in sys.argv:
        sys.exit(0 if updated == current else 1)
    WIDGET.write_text(updated, encoding="utf-8")
    print("widget.html data block now matches attention.py")
show_numbers.py53 linesDownload show_numbers.py
Show code
"""Print every number the lesson quotes: the toy's weights for "it", the full grids, the scaling experiment and the cost.

    python3 show_numbers.py
"""
import math

import attention as att

IT = att.TOKENS.index("it")


def show_it():
    print('The word "it" under the first head (what a word points to). d = 2 numbers per query, so scores are divided by sqrt(2) = %.4f' % math.sqrt(2))
    run = att.attend(att.VECTORS, att.HEADS["refers"])
    print("  query of it:", run["queries"][IT])
    print("  %-8s %-10s %-10s %s" % ("word", "key", "score", "weight"))
    for j, t in enumerate(att.TOKENS):
        score = "masked" if j > IT else "%.3f" % run["scores"][IT][j]
        print("  %-8s %-10s %-10s %.4f" % (t, run["keys"][j], score, run["weights"][IT][j]))
    print("  blended note of it:", [round(x, 3) for x in run["outputs"][IT]], "  about:", att.ABOUT)


def show_grids():
    for name, head in att.HEADS.items():
        run = att.attend(att.VECTORS, head)
        print("\nWeights in percent, head %r (%s). Row = the word asking, column = the word it looks at." % (name, att.HEAD_LABELS[name]))
        print("  %-8s" % "" + "".join("%8s" % t for t in att.TOKENS))
        for i, t in enumerate(att.TOKENS):
            print("  %-8s" % t + "".join("%8s" % att.percent(w) if j <= i else "%8s" % "." for j, w in enumerate(run["weights"][i])))
        print("  sentence:", att.view(name, IT)["sentence"], "|", att.view(name, IT)["blend"])
    both = att.multi_head(att.VECTORS, [att.HEADS["refers"], att.HEADS["before"]])
    print("\nTwo heads joined, outputs for it:", [round(x, 3) for x in both[IT]])


def show_scaling():
    print("\nScores of one random query against 8 random keys, 100 trials (numbers of mean 0 and spread 1, seed 6)")
    print("  %-6s %-22s %-22s %s" % ("d", "spread of scores", "biggest weight", "sqrt(d)"))
    print("  %-6s %-10s %-10s %-10s %-10s" % ("", "no divide", "divide", "no divide", "divide"))
    for r in att.scaling_experiment():
        print("  %-6d %-10.2f %-10.2f %-10.2f %-10.2f %.1f" % (r["d"], r["spread_raw"], r["spread_scaled"], r["top_raw"], r["top_scaled"], math.sqrt(r["d"])))


def show_cost():
    print("\nScores one head makes over a prompt, and for %d layers x %d heads" % (att.LAYERS, att.HEADS_PER_LAYER))
    for n in (10, 1000, 2000, 8000):
        print("  %6d words: %14s per head, %18s in all" % (n, f"{att.score_count(n):,}", f"{att.score_count(n) * att.LAYERS * att.HEADS_PER_LAYER:,}"))


if __name__ == "__main__":
    show_it()
    show_grids()
    show_scaling()
    show_cost()
test_attention.py105 linesDownload test_attention.py
Show code
"""Guards for attention.py: the toy's weights, the mask, weights that add up to 1, an independent NumPy check, the scaling numbers."""
import math
import random
import re
from pathlib import Path

import numpy as np

import attention as att

TOL = 1e-12                                        # agreement with NumPy, in absolute weight or output units
IT = att.TOKENS.index("it")


def test_the_toys_weights_for_it_match_the_hand_worked_numbers():
    """Guards a wrong weight: head 1 gives "it" a score of 3 / sqrt(2) on "cat" and 0 on the others, so cat gets e^2.121 / (e^2.121 + 4)."""
    refers = att.attend(att.VECTORS, att.HEADS["refers"])
    scores, weights = refers["scores"][IT], refers["weights"][IT]
    cat = att.TOKENS.index("cat")
    assert scores[:IT + 1] == [0.0, 3 / math.sqrt(2), 0.0, 0.0, 0.0] and scores[IT + 1:] == [-math.inf] * 2
    assert weights[cat] == math.exp(3 / math.sqrt(2)) / (math.exp(3 / math.sqrt(2)) + 4) and round(weights[cat], 3) == 0.676
    assert [round(w, 3) for w in weights] == [0.081, 0.676, 0.081, 0.081, 0.081, 0.0, 0.0]
    for word, size, weight in (("sat", 3.57, 0.641), ("tired", 5.46, 0.476)):                     # the other words the lesson works by hand
        row = att.TOKENS.index(word)
        assert round(math.exp(refers["scores"][row][cat]), 2) == size and round(refers["weights"][row][cat], 3) == weight
    assert refers["weights"][att.TOKENS.index("because")][:4] == [0.25] * 4                      # a query of all zeros: every score is 0
    before = att.attend(att.VECTORS, att.HEADS["before"])["weights"][IT]
    assert max(range(IT + 1), key=lambda j: before[j]) == att.TOKENS.index("because") and round(max(before), 2) == 0.71
    even = att.attend(att.VECTORS, att.HEADS["even"])["weights"][IT]
    assert even[:IT + 1] == [0.2] * 5
    assert att.view("refers", IT)["sentence"] == "“it” puts 68% of its weight on “cat”."
    assert att.view("refers", 0)["sentence"] == "“The” puts 100% of its weight on “The”."
    assert att.view("even", IT)["sentence"] == "“it” spreads its weight evenly: 20% on each of 5 words."
    assert att.view("refers", IT)["blend"] == "Blended note, “an animal”: 0.68. “it” alone: 0.00."
    assert len(att.VECTORS) == len(att.TOKENS) == 7 and {len(v) for v in att.VECTORS} == {att.D_MODEL}


def test_weights_are_positive_add_to_one_and_a_word_never_looks_ahead():
    """Guards the two failures a reader would see: a weight that is negative or the row not adding to 1, and a word whose answer changes
    when a LATER word changes. Runs every head with the division on and off, then rewrites every word after word i and checks rows 0..i."""
    n = len(att.TOKENS)
    for name, head in att.HEADS.items():
        for scale in (True, False):
            run = att.attend(att.VECTORS, head, scale)
            for i in range(n):
                row = run["weights"][i]
                assert abs(sum(row) - 1) < TOL and all(w > 0 for w in row[:i + 1]) and row[i + 1:] == [0.0] * (n - 1 - i), (name, scale, i)
                changed = [v if j <= i else [x + 1.7 for x in v] for j, v in enumerate(att.VECTORS)]
                other = att.attend(changed, head, scale)
                assert other["outputs"][i] == run["outputs"][i] and other["weights"][i] == row, (name, scale, i)


def reference(x, wq, wk, wv, scale=True):
    """Attention as NumPy matrix operations: softmax(Q K^T / sqrt(d) + mask) V, with the mask as -inf above the diagonal."""
    q, k, v = x @ wq, x @ wk, x @ wv
    s = q @ k.T / (math.sqrt(k.shape[1]) if scale else 1.0)
    s = s + np.triu(np.full(s.shape, -np.inf), k=1)
    e = np.exp(s - s.max(axis=1, keepdims=True))
    w = e / e.sum(axis=1, keepdims=True)
    return w, w @ v


def test_matches_numpy_on_the_toy_and_on_random_inputs():
    """Guards a slip in any one step (the division, the mask, the softmax, the average): both routes must agree within 1e-12."""
    rng = random.Random(0)
    cases = [(att.VECTORS, h) for h in att.HEADS.values()]
    for n, d_model, d_k, d_v in [(1, 3, 2, 2), (5, 4, 3, 4), (9, 8, 8, 5), (12, 16, 4, 6)]:
        def table(rows, cols):
            return [[rng.gauss(0, 1) for _ in range(cols)] for _ in range(rows)]
        cases.append((table(n, d_model), {"wq": table(d_model, d_k), "wk": table(d_model, d_k), "wv": table(d_model, d_v)}))
    for vectors, head in cases:
        for scale in (True, False):
            run = att.attend(vectors, head, scale)
            w, out = reference(np.array(vectors), np.array(head["wq"]), np.array(head["wk"]), np.array(head["wv"]), scale)
            assert np.allclose(run["weights"], w, rtol=0, atol=TOL) and np.allclose(run["outputs"], out, rtol=0, atol=TOL)


def test_two_heads_are_joined_side_by_side_without_mixing():
    """Guards heads sharing numbers: each word's joined output is head 1's output followed by head 2's, each as when run alone."""
    both = att.multi_head(att.VECTORS, [att.HEADS["refers"], att.HEADS["before"]])
    one, two = att.attend(att.VECTORS, att.HEADS["refers"])["outputs"], att.attend(att.VECTORS, att.HEADS["before"])["outputs"]
    assert both == [a + b for a, b in zip(one, two)] and len(both[IT]) == 2 * att.D_V


def test_scaling_experiment_gives_the_numbers_the_lesson_prints():
    """Guards the lesson's claim: without the division the scores spread out in step with sqrt(d) and the biggest word takes almost all
    the weight; with it the spread stays near 1 at every length."""
    rows = {r["d"]: r for r in att.scaling_experiment()}
    assert {d: round(r["spread_raw"], 1) for d, r in rows.items()} == {4: 2.2, 64: 8.1, 1024: 31.6}
    assert {d: round(r["spread_scaled"], 2) for d, r in rows.items()} == {4: 1.08, 64: 1.01, 1024: 0.99}
    assert {d: round(r["top_raw"], 2) for d, r in rows.items()} == {4: 0.53, 64: 0.90, 1024: 0.96}
    assert {d: round(r["top_scaled"], 2) for d, r in rows.items()} == {4: 0.34, 64: 0.36, 1024: 0.36}
    assert all(abs(r["spread_raw"] / math.sqrt(d) - 1) < 0.1 for d, r in rows.items())


def test_cost_counts_match_a_brute_force_count_and_lesson_23():
    """Guards the n-squared-over-2 claim and the copied model shape: the formula equals the number of unmasked cells, the three printed
    figures are right, and the layer and head counts are the ones in lesson 23's prefill_sim.py."""
    for n in range(1, 25):
        assert att.score_count(n) == sum(1 for i in range(n) for j in range(n) if j <= i)
    assert [att.score_count(n) for n in (1000, 8000)] == [500_500, 32_004_000]
    assert att.score_count(8000) * att.LAYERS * att.HEADS_PER_LAYER == 32_772_096_000
    other = (Path(__file__).resolve().parents[2] / "why-ai-waits-before-the-first-word" / "code" / "prefill_sim.py").read_text(encoding="utf-8")
    assert int(re.search(r"^LAYERS = (\d+)", other, re.M).group(1)) == att.LAYERS
    assert int(re.search(r"^HEADS = (\d+)", other, re.M).group(1)) == att.HEADS_PER_LAYER
test_widget.py243 linesDownload test_widget.py
Show code
"""Guards for ../widget.html: its data block, its size, that its JavaScript gives the same weights and words as attention.py, and what a reader sees."""
import html
import json
import math
import re
import subprocess

import attention as att
from embed_widget import CONFIG_BLOCK, WIDGET, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="attn-root">'):HTML.index("<script")]
TOLERANCE = 1e-12                                   # the JavaScript and Python weights and outputs may differ by at most this much

# Reads the model out of the widget between its marker comments, runs it with Node, and compares the weights and outputs with the numbers
# Python wrote into the fixture (within 1e-12) and the words and percentages as exact strings, for every head and every word.
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget, tolerance] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const cfg = JSON.parse(html.match(/<script type="application\/json" id="attn-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the model ('), html.indexOf('/* ---------- display'));
const api = new Function('cfg', "'use strict';" + js + '; return {attend: attend, view: view};')(cfg);
const tol = Number(tolerance), bad = [];
let checks = 0, worst = 0;
const near = (a, b) => { const d = Math.abs(a - b); worst = Math.max(worst, d); return d <= tol; };
Object.keys(fx.heads).forEach((name) => {
  const run = api.attend(name), want = fx.heads[name];
  want.weights.forEach((row, i) => row.forEach((w, j) => { checks++; if (!near(run.weights[i][j], w)) bad.push(['weight', name, i, j, run.weights[i][j], w]); }));
  want.outputs.forEach((row, i) => row.forEach((o, c) => { checks++; if (!near(run.outputs[i][c], o)) bad.push(['output', name, i, c, run.outputs[i][c], o]); }));
  want.scores.forEach((row, i) => row.forEach((s, j) => {
    checks++;
    const got = run.scores[i][j];
    if (s === null ? got !== -Infinity : !near(got, s)) bad.push(['score', name, i, j, got, s]);
  }));
  want.views.forEach((v, i) => {
    checks++;
    if (JSON.stringify({ ...api.view(name, i), top: undefined }) !== JSON.stringify({ ...v, top: undefined })) bad.push(['view', name, i, api.view(name, i), v]);
  });
});
console.log(JSON.stringify({ checks, worst, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, presses every word and every head button, and reads back what a reader sees.
PAGE = r"""
import fs from 'node:fs';
const [widget, fixture] = process.argv.slice(2);
const html = fs.readFileSync(widget, 'utf8');
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const config = html.match(/<script type="application\/json" id="attn-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, style: {}, attrs: {}, textContent: '' };
  e.setAttribute = (k, v) => { e.attrs[k] = v; };
  e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
  e.appendChild = (c) => (e.children.push(c), c);
  return e;
}
function open() {
  const page = { root: el('html'), messages: [], parent: { other: true } };
  fx.ids.forEach((id) => { page[id] = el('x'); });
  page['attn-config'].textContent = config;
  const doc = { getElementById: (id) => page[id], createElement: el, documentElement: page.root };
  const win = { addEventListener: (type, f) => page.messages.push(f) };
  new Function('document', 'window', 'parent', code)(doc, win, page.parent);
  return page;
}
const fire = (e) => (e.listeners.click || []).forEach((f) => f({ target: e }));
const bad = [];
const want = (what, got, expected) => { if (JSON.stringify(got) !== JSON.stringify(expected)) bad.push([what, got, expected]); };
const pct = (c) => (c === '<1' ? 0.5 : Number(c));
const mix = (td) => { const m = /var\(--green\) (\d+)%/.exec(td.style.background || ''); return m ? Number(m[1]) : NaN; };
function look(page, head, i, what) {                 // compares the page with Python's view of one head and one selected word
  const v = fx.views[head][i], rows = page['attn-bars'].children;
  want(what + ': sentence', page['attn-sentence'].textContent, v.sentence);
  want(what + ': blend', page['attn-blend'].textContent, v.blend);
  want(what + ': scores line', page['attn-scores'].textContent, v.scoresLine);
  want(what + ': word pressed', page['attn-tokens'].children.map((b) => b.attrs['aria-pressed']), fx.tokens.map((t, k) => String(k === i)));
  want(what + ': head pressed', page['attn-heads'].children.map((b) => b.attrs['aria-pressed']), fx.names.map((n) => String(n === head)));
  want(what + ': percent column', rows.map((li) => li.children[2].textContent), v.weights.map((w) => (w === '' ? '' : w + '%')));
  want(what + ': bar widths', rows.map((li) => li.children[1].children[0].style.width), v.widths);
  want(what + ': later words say so', rows.map((li) => [li.attrs['data-later'], li.children[1].children[1].style.display]), v.weights.map((w) => [String(w === ''), w === '' ? '' : 'none']));
  want(what + ': the biggest is marked', rows.map((li) => li.attrs['data-top']), v.weights.map((w, j) => String(j === v.top)));
  const grid = page['attn-grid'].children;
  want(what + ': grid size', grid.length, fx.tokens.length + 1);
  want(what + ': grid numbers', grid.slice(1).map((tr) => tr.children.slice(1).map((td) => td.textContent)), v.grid);
  // shaded only where the word looks; in a row whose weights differ the biggest cell is mixed with more green than the smallest, and no mix passes 50%
  want(what + ': grid shading only where looked, darker for a bigger weight, never above half green', grid.slice(1).map((tr, r) => {
    const cells = tr.children.slice(1), shares = v.grid[r].map((c) => (c === '' ? NaN : pct(c))), seen = shares.map((s, j) => j).filter((j) => !Number.isNaN(shares[j]));
    const mixes = cells.map((td) => ((td.style.background || '') !== '' ? mix(td) : NaN));
    const big = seen.reduce((a, j) => (shares[j] > shares[a] ? j : a), seen[0]), small = seen.reduce((a, j) => (shares[j] < shares[a] ? j : a), seen[0]);
    return [cells.map((td) => (td.style.background || '') !== ''), shares[big] > shares[small] ? mixes[big] > mixes[small] : true, Math.max(...seen.map((j) => mixes[j])) <= 50];
  }), v.grid.map((r) => [r.map((c) => c !== ''), true, true]));
  want(what + ': grid row marked', grid.slice(1).map((tr) => tr.attrs['data-selected']), fx.tokens.map((t, k) => String(k === i)));
}

const first = open();
look(first, fx.opening.head, fx.opening.token, 'opening state');
want('nothing spoken yet', first['attn-live'].textContent, '');
want('word buttons', first['attn-tokens'].children.map((b) => b.textContent), fx.tokens);
want('head buttons', first['attn-heads'].children.map((b) => b.textContent), fx.names.map((n) => fx.labels[n]));
want('grid headers', first['attn-grid'].children[0].children.slice(1).map((th) => th.children[0].textContent), fx.tokens);

// every head with every word, by pressing the buttons
const page = open();
fx.names.forEach((head, h) => {
  fire(page['attn-heads'].children[h]);
  fx.tokens.forEach((t, i) => {
    fire(page['attn-tokens'].children[i]);
    look(page, head, i, head + ' / ' + t);
    want(head + ' / ' + t + ': spoken', page['attn-live'].textContent, fx.views[head][i].sentence + ' ' + fx.views[head][i].blend);
  });
});

// the host page theme: only the parent window may set it, and only to light or dark
const themed = open();
const send = (data, source) => themed.messages.forEach((f) => f({ data, source }));
send({ type: 'spicyshark:theme', theme: 'dark' }, { other: false });
send({ type: 'spicyshark:theme', theme: 'purple' }, themed.parent);
send({ type: 'other', theme: 'dark' }, themed.parent);
send(null, themed.parent);
want('ignored messages', themed.root.attrs, {});
send({ type: 'spicyshark:theme', theme: 'dark' }, themed.parent);
want('theme from the parent', themed.root.attrs, { 'data-theme': 'dark' });
send({ type: 'spicyshark:theme', theme: 'light' }, themed.parent);
want('theme back to light', themed.root.attrs, { 'data-theme': 'light' });

console.log(JSON.stringify({ heads: fx.names.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""


def run_node(tmp_path, script, fixture, args):
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "check.mjs").write_text(script, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "check.mjs"), *args(tmp_path)], capture_output=True, text=True, timeout=120)
    assert run.returncode == 0, run.stdout + run.stderr
    return json.loads(run.stdout)


def share(text):
    return 0.5 if text == "<1" else float(text)


def test_widget_data_block_matches_the_python_numbers():
    """Guards a toy edit that was not copied into the widget (run embed_widget.py), and an embed that skips a drifted block."""
    assert embed(HTML) == HTML
    drifted = HTML.replace('"opening": {"head": "refers"', '"opening": {"head": "even"', 1).replace('"tokens": ["The", "cat"', '"tokens": ["A", "cat"', 1)
    assert drifted != HTML and embed(drifted) == HTML


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the formulas, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    missing = sorted(i for i in lookups if i != "attn-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="attn-config"' in HTML


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 260 and len(HTML.encode("utf-8")) < 40 * 1024
    page = CONFIG_BLOCK.sub("", HTML)
    for banned in ("http://", "https://", "fetch(", "XMLHttpRequest", "WebSocket", "sendBeacon", "import("):
        assert banned not in page, banned
    assert " src=" not in page and "@import" not in page
    assert "<form" not in HTML and 'type="submit"' not in HTML          # a form cannot submit inside the lesson page's sandboxed frame
    assert "transition" not in HTML and "animation" not in HTML         # nothing moves, so prefers-reduced-motion has nothing to switch off


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the row of words counts as one control, each head button as one, and the grid sits in a closed details element."""
    outside = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) + 1 + len(att.HEADS) <= 5
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert re.search(r"<details><summary>More settings</summary>.*?id=\"attn-grid\"", ROOT, re.S)


def test_widget_keeps_the_site_theme_listener_and_palette():
    assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML and "parent === window" in HTML
    for pair in ("--bg: light-dark(#ffffff, #0a0f17)", "--panel: light-dark(#ebf0f6, #131b28)", "--ink: light-dark(#0a1626, #f4f7fb)",
                 "--mute: light-dark(#44566b, #a3b1c4)", "--ctl: light-dark(#66768a, #7a8a9f)", "--ink-on: light-dark(#f6f8fb, #05070c)",
                 "--green: light-dark(#00855F, #009E73)", ":root[data-theme=light]", ":root[data-theme=dark]"):
        assert pair in HTML, pair
    for banned in ("#0072B2", "#56B4E9", "#D55E00"):
        assert banned.lower() not in HTML.lower()
    assert not set(re.findall(r"#[0-9a-fA-F]{3,8}\b", HTML)) - {"#ffffff", "#0a0f17", "#ebf0f6", "#131b28", "#0a1626", "#f4f7fb", "#44566b", "#a3b1c4",
                                                               "#66768a", "#7a8a9f", "#f6f8fb", "#05070c", "#00855F", "#009E73"}


def test_widget_formulas_give_the_weights_and_words_of_the_python_toy(tmp_path):
    """Guards the widget's JavaScript drifting from attention.py: the lesson prints Python's numbers and the reader sees the widget's, so
    the two must agree within 1e-12 on every weight, score and output, and exactly on every percentage and sentence, for every head and word."""
    heads = {}
    for name, head in att.HEADS.items():
        run = att.attend(att.VECTORS, head)
        heads[name] = {"weights": run["weights"], "outputs": run["outputs"],
                       "scores": [[None if s == -math.inf else s for s in row] for row in run["scores"]],
                       "views": [att.view(name, i) for i in range(len(att.TOKENS))]}
    result = run_node(tmp_path, PARITY, {"heads": heads}, lambda d: [str(d / "fixture.json"), str(WIDGET), str(TOLERANCE)])
    n, h = len(att.TOKENS), len(att.HEADS)
    assert result["bad"] == [] and result["checks"] == h * (2 * n * n + n * att.D_V + n) and result["worst"] <= TOLERANCE


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: it opens on "it" looking for what it points to with 68% on "cat", every head and every word give
    the bars, sentence, blend line and grid Python computes, later words say they are not looked at, the buttons speak, and the grid figure
    prints the same percentages."""
    opening = att.view("refers", att.TOKENS.index("it"))
    assert opening["sentence"] == "“it” puts 68% of its weight on “cat”." and json.loads(CONFIG_BLOCK.search(HTML).group(2))["opening"] == att.config()["opening"]
    visible = re.sub(r"<details>.*?</details>", "", ROOT, flags=re.S)                                                                 # what shows without opening anything
    for line in ("A toy: every number is picked by hand, not learned.", "A weight is a share of attention."):
        assert line in visible, line
    assert 'class="attn-sr" id="attn-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT                               # what a screen reader hears
    assert "grid-template-columns: repeat(auto-fit, minmax(var(--min), 1fr))" in HTML and ".attn-root .attn-row button { white-space: nowrap;" in HTML   # an even grid: a button wraps whole, never its label, never stretched
    labels = re.findall(r'class="attn-label"[^>]*>([^<]+)<', ROOT) + list(att.HEAD_LABELS.values())
    assert len(labels) == 2 + len(att.HEADS) and not [t for t in labels if {w.lower() for w in t.split()} & {w.lower() for w in att.TOKENS}]   # no label reads as a word of the sentence ("it")
    lesson, legend = (WIDGET.parent / "lesson.md").read_text(encoding="utf-8"), (WIDGET.parent / "figs" / "two-heads.svg").read_text(encoding="utf-8")
    for key in ("refers", "before"):                                                                                                  # figure 3 and its caption print the widget's own labels
        label = att.HEAD_LABELS[key]
        assert f">{label}</text>" in legend and f"labelled {label}," in legend and f'"{label}"' in lesson, key        # drawn legend, the figure's description, the caption
    named = set(re.findall(r'In the head (?:called )?"([^"]+)"', lesson))                                                             # and so does every sentence that names a head
    assert named and named <= set(att.HEAD_LABELS.values()), named
    grid_svg = (WIDGET.parent / "figs" / "grid.svg").read_text(encoding="utf-8")                                                     # the figure prints the percentages Python computes
    assert [html.unescape(t) for t in re.findall(r'text-anchor="middle"[^>]*>([^<]+)</text>', grid_svg)] == [c for row in att.view("refers", len(att.TOKENS) - 1)["grid"] for c in row if c]
    views = {}
    for name in att.HEADS:
        views[name] = []
        for i in range(len(att.TOKENS)):
            v = att.view(name, i)
            top = -1 if (i > 0 and len(set(v["weights"][:i + 1])) == 1) else max(range(i + 1), key=lambda j: (share(v["weights"][j]), -j))
            v["top"] = top
            v["widths"] = [f"{share(w):g}%" if w != "" else "0%" for w in v["weights"]]
            v["scoresLine"] = (f"Scores before softmax for “{att.TOKENS[i]}”: "
                               + ", ".join(f"{att.TOKENS[j]} {s}" for j, s in enumerate(v["scores"]) if s != "") + ".")
            views[name].append(v)
    ids = sorted(set(re.findall(r"\$\('([\w-]+)'\)", HTML[HTML.index("<script>"):])))
    fixture = {"ids": ids, "tokens": att.TOKENS, "names": list(att.HEADS), "labels": att.HEAD_LABELS, "views": views, "opening": att.config()["opening"]}
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result == {"heads": len(att.HEADS), "bad": []}