Why does AI make things up?

A language model writes the likely next words, not checked facts. Where its training text says little, a fluent answer can be wrong, and it rarely says so.

  • 6 min read
  • 11 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

Ask a chatbot for a study on sleep and memory, and it may give you authors, a journal and a year, in the right format, for a study that does not exist. It is not lying: it wrote the kind of text that usually follows such a question. This lesson uses a toy, a tiny program that learned from 37 short sentences, 30 of them like “The capital of France is Paris.” Nothing it read says the capital of Peru. Asked to finish “The capital of Peru is”, it picks Paris, which has the biggest share of any word, 10.5%. The true answer, Lima, gets 0.0%. Real models are far bigger; the lesson says where the toy misleads.

In one minute

AI chatbots make things up because they are built to write the most likely next words, and the most likely words are not always the true ones. Picture a person who has read a huge pile of text and finishes your sentences the way such sentences usually go. For a famous fact, the usual ending is the right one. For a rare name, an exact number or a recent event, the pile says little, but a smooth ending is still easy to write. Nothing in the basic design checks the fact, so a made-up answer sounds as sure as a true one.

Explore the numbers

Open the widget. It is the toy from the opening, starting on Peru. Move the slider to add copies of the true sentence, “The capital of Peru is Lima.”: one copy flips the answer to Lima, at 51.2%. Tick the box that allows “I don’t know” when the top word is below 50%: with no copy added the toy declines to answer, and with one copy it answers Lima. Then move the temperature slider and watch Paris grow while Lima stays at 0.0%.

How it works

A language model writes the likely next words

A language model is a program that learned, from a very large amount of text (its training text), which words tend to follow which. To answer, it picks a likely next word, adds it to the text, and repeats, one word at a time. Real models work in tokens, small chunks of text (What is a token? explains them), but the loop is the same. Nothing in it looks a fact up or compares the sentence with a source. IBM’s explainer (read 2026-10-19) says a generative model aims at what fits the pattern in its training data, not at what is true.

Boxes joined by arrows: question, pick a likely next word, repeat, fluent answer. A dashed box, check against a source, joins nothing and is labelled not built in.

Where the text says little, a fluent answer can be wrong

Some facts show up many times in text. The toy read “The capital of France is Paris.” five times, and asked about France it answers Paris with an 85.1% chance. Other facts show up once or never. The toy never read Peru’s capital, yet it must choose a word after “is”, and in what it read, those are almost all capitals. So it chooses the capital it saw most often, Paris, and gives Lima 0.0%. The answer is fluent because it fits the pattern, and wrong because the pattern is not the fact.

Bars for the toy’s chance of each word after The capital of Peru is: Paris 10.5%, Rome and Tokyo 6.5%, Berlin and Cairo 4.5%. The true word, Lima, has no bar: 0.0%.

Real models are not this blunt: they squeeze their training text into numbers, and can often answer a question worded in a way they never saw. Still, rare facts are a weak spot. Kandpal and colleagues (2022; abstract read 2026-10-19) report that a model’s ability to answer a fact question relates to how many documents about it appeared in training. Kalai and colleagues (2025; read 2026-10-19) argue that for facts with no pattern to learn, such as a birthday, a model fresh from its training text should get wrong at least the share of them seen exactly once, provided its confidence is calibrated (it matches how often the model is right).

Why tests push towards a guess

Builders compare and choose models by their scores on tests. Most tests give 1 point for a right answer and 0 for a wrong one, and also 0 for “I don’t know”. So a guess can only gain points, and a model that always guesses scores higher than one that sometimes says “I don’t know”. The 2025 paper argues that hallucinations (answers that sound right but are false) persist because most tests are graded this way. The toy has no test score: this cause is the paper’s argument, not something the toy shows.

Points on a right-or-wrong test: answer and be right, 1 point; answer and be wrong, 0; say I don’t know, 0. A wrong guess costs no more than a blank.

Why a lower temperature does not make facts true

Many chat tools have a setting called temperature that controls how adventurous the word choice is: at a low setting the model almost always takes its top choice, and at a high one it takes more varied words. Lowering it makes answers more repeatable, not truer: the top word is taken whether or not it is true. In the toy, going from the counted chances (setting 1) to the lowest setting lifts Paris from 10.5% to 98.3%, and Lima stays at 0.0%.

Bars for the toy’s chance of Paris at seven temperature settings from 0.1 to 2, including 98.3% at 0.1, 57.4% at 0.3, 10.5% at 1 and 4.2% at 2. Lima is 0.0% at every setting.

What this means when you build

  • Give the model the text and ask it to quote. If the answer should come from a document, put the document in the prompt (the text you send the model) and tell the model to use only that document. For a long document (Anthropic’s guide, read 2026-10-19, gives about 20,000 tokens as “long”), ask it to pull out the relevant quotes first. Your app can then check each quote with a plain text search.
  • Ask for sources, then check them. The same guide describes asking the model to cite a quote for each claim and to withdraw any claim it cannot back. Open and read any link, paper or court case it gives you before you rely on it.
  • Let it say “I don’t know”. Tell the model in the prompt that it may, and build your app to show and handle that answer, not retry until something else comes back.
  • Do not count on temperature. Turning it down makes a wrong answer steady, not right.
  • Keep a small set of questions whose answers you know, run it after every change to the prompt or the model, and count the misses (a later lesson on test sets covers how). The guide ends with the same warning: its techniques reduce hallucinations and do not remove them.

Optionalabout 11 min

Go deeper

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

The toy: count what follows what

The toy has no neural network. It splits text into words and the marks . , ! ? (tokenize), then counts, for every pair of words and for every single word, which word came next. Every sentence starts after two <s> marks, so the first word of a line also follows something.

def train(lines):
    pairs, singles, vocab = {}, {}, set()
    for line in lines:
        words = [START, START] + tokenize(line)
        vocab.update(words[2:])
        for i in range(2, len(words)):
            for table, context in ((pairs, tuple(words[i - 2:i])), (singles, (words[i - 1],))):
                followers = table.setdefault(context, {})
                followers[words[i]] = followers.get(words[i], 0) + 1
    return {"pairs": pairs, "singles": singles, "vocab": sorted(vocab)}

To answer, it blends three estimates from vague to specific: a flat guess (every word of the corpus equally likely), what followed the last word, and what followed the last two words. Each blend uses a standard smoothing rule called Witten-Bell: the trust in a context is seen / (seen + kinds), where seen is how many words followed it and kinds is how many different ones. A context followed by 31 words of 19 kinds earns trust 31/50 = 0.62, and the other 0.38 of the weight goes to the vaguer estimate below it.

def next_word_probs(model, prompt):
    words = [START, START] + tokenize(prompt)
    vocab = model["vocab"]
    if not vocab:
        return {}
    probs = {w: 1 / len(vocab) for w in vocab}
    for table, context in ((model["singles"], tuple(words[-1:])), (model["pairs"], tuple(words[-2:]))):
        followers = table.get(context)
        if not followers:
            continue
        seen = sum(followers.values())
        trust = seen / (seen + len(followers))      # Witten-Bell: seen / (seen + kinds of follower)
        probs = {w: trust * followers.get(w, 0) / seen + (1 - trust) * probs[w] for w in vocab}
    return probs

(Docstrings are left out here.) The probabilities add up to 1, and a test checks that.

One probability by hand

Two numbers from the opening, worked step by step. The first table is the plain corpus and the word Paris. The second adds one copy of “The capital of Peru is Lima.” and follows the word Lima.

step what value
1 different words in the corpus, so the flat guess is 1/77 0.0130
2 words that followed “is” 31
3 different words that followed “is” 19
4 trust in “is”: seen / (seen + kinds) = 31/50 0.62
5 times Paris followed “is” 5
6 one-word estimate: 0.62 x 5/31 + 0.38 x 1/77 0.1049
7 words that followed “Peru is”: never seen, so skip none
8 final chance 0.1049
step what value
1 different words in the corpus, so the flat guess is 1/78 0.0128
2 words that followed “is” 32
3 different words that followed “is” 20
4 trust in “is”: 32/52 0.6154
5 times Lima followed “is” 1
6 one-word estimate: 0.6154 x 1/32 + 0.3846 x 1/78 0.0242
7 words that followed “Peru is”: 1 word, 1 kind, so trust 1/2 0.5
8 final chance: 0.5 x 1 + 0.5 x 0.0242 0.5121

Because “Peru is” was never read, the toy skips it and the answer rests on the word “is” alone, which is why Peru is treated like any other country. After one added sentence “Peru is” has a single follower, Lima, and half of the weight goes to “Lima, every time”. That is why one sentence moves Lima from 0.0% to 51.2%.

Adding true sentences: the smallest k

The code adds k copies of the true sentence and asks how few are enough. answer returns the top word, or “I don’t know” when even the top word is under a bar. smallest_k tries k = 0, 1, 2, ... until the answer is the true word.

def answer(probs, threshold=0.0):
    if not probs:
        return IDK
    word, p = ranked(probs)[0]
    return word if p >= threshold else IDK


def smallest_k(lines, prompt, true_sentence, truth, threshold=0.0, temperature=1.0, limit=50):
    for k in range(limit + 1):
        probs = with_temperature(next_word_probs(model_with_extra(lines, true_sentence, k), prompt), temperature)
        if answer(probs, threshold) == truth:
            return k
    return None
k chance of Lima chance of Paris
0 0.0% 10.5%
1 51.2% 5.1%
2 68.1% 3.3%
3 76.5% 2.4%
4 81.5% 1.9%
5 84.9% 1.6%
6 87.3% 1.3%
7 89.1% 1.1%
8 90.4% 1.0%

With no bar, smallest_k is 1 for Peru, and also for Laos and Malta. If the toy may answer only when its top word has at least 80%, the answer is 4 (81.5%, where 3 copies give 76.5%). At 90% it is 8.

Lines for the chance of Lima and of Paris against the number of added true sentences, 0 to 8. Lima rises from 0.0% to 51.2% at 1, 81.5% at 4 and 90.4% at 8. Paris falls from 10.5% to 1.0%.

One sentence is enough here only because the toy repeats what it read word for word, and counts a fact written in other words as unseen. A real model keeps a blurred copy of its text, and facts it saw rarely are the ones it gets wrong most (Kandpal and colleagues link accuracy to how often a fact appears; Kalai and colleagues put a floor under errors for facts seen exactly once). Read k as a way to see the direction of the effect, not as a number to carry over to real models.

An “I don’t know” bar

With the bar in answer, the toy declines when it is not sure. These are the results for the Peru question with no sentence added.

bar the toy says
5% Paris
10% Paris
11% I don’t know
30% I don’t know
50% I don’t know

At a 50% bar the toy answers Lima after one added copy, and it still answers Paris to the France question, where it has 85.1%. The bar works because the toy is unsure while it guesses (10.5%) and sure when it has read the fact five times. The cost is that a fact it has read only a little stays under a high bar: at 90% it needs 8 copies.

Kalai and colleagues propose the same idea for tests. The question states a confidence target t, a right answer scores 1, “I don’t know” scores 0, and a wrong answer costs t / (1 - t) points: for t = 0.5 the penalty is 1 and for 0.9 it is 9 (two of the paper’s own examples). Answering has a positive average score only when the chance of being right is above t: p - (1 - p) x t/(1 - t) is above 0 exactly when p is above t. A real model’s confidence need not match how often it is right, so any bar like this has to be tested against known answers.

Temperature in the toy

with_temperature raises every chance to the power 1 / temperature and scales the results back to a total of 1.

def with_temperature(probs, temperature):
    powered = {w: p ** (1 / temperature) for w, p in probs.items()}
    total = sum(powered.values())
    return {w: p / total for w, p in powered.items()}
temperature Paris at a 50% bar
0.1 98.3% Paris
0.2 80.1% Paris
0.3 57.4% Paris
0.5 30.3% I don’t know
1.0 10.5% I don’t know
1.5 5.9% I don’t know
2.0 4.2% I don’t know

Lima stays at 0.0% because zero to any power is zero: no setting can lift a word that has no chance. The last column shows a trap. The bar is checked after the temperature is applied, so at 0.3 Paris reaches 57.4% and passes the 50% bar, and an honest “I don’t know” turns into a confident wrong answer. Checking the bar on the counted chances instead would avoid this in the toy; the point is that a firmer-sounding answer is not evidence of a better one.

Proof it works

python3 -B -m pytest -q -p no:cacheprovider code/

The tests check these claims. The chances add up to 1 for seven prompts (including an empty one and one with unseen words) at three amounts of added text, and after a temperature change. On the thin corpus the top word is Paris at 10.49% for Peru, Laos and Malta alike, the true word has 0%, and neither it nor the pair “Peru is” appears anywhere in the corpus. Hand calculations match the two tables above for both Paris (0.62 x 5/31 + 0.38/77) and Lima. The smallest k is 1, and 4 and 8 at the 80% and 90% bars. The bar and the search limit are each tested on both sides of the boundary, so the search returns None when the limit is one too low. The bar gives “I don’t know” at 50% with no copy, still answers Paris at 10%, answers Lima after one copy and Paris for France. A lower temperature raises Paris and never the true word, and temperature 1 changes nothing. Ties go to the word that sorts first, whatever the order of the corpus, and an empty corpus gives “I don’t know”. The tokenizer test feeds in Peru's capital, Lima! Café 2026? and checks that the accented letter is skipped (Caf), because only the plain letters, digits, apostrophes and four marks are read.

widget.html runs the same arithmetic in JavaScript, so a second test, test_widget.py, runs the widget’s own code in Node and compares it with the Python code: the tokenizer, the vocabulary, every chance to 12 decimal places, the ranking, the answer and the smallest k, on five corpora, ten prompts and several values of k, temperature and bar (more than a thousand comparisons). One of the corpora is a single word with a bar of 100%, which checks the case where a chance sits exactly on the bar. embed_widget.py --check fails if the corpus or the questions in the widget differ from corpus.txt and prompts.py.

Where this is used in practice

The public record is mostly papers and documentation. Kalai and colleagues link the pretraining side (facts with no pattern to learn, a share of which appear only once) to the grading side (tests that give “I don’t know” nothing). Their example is a what-if, not a measurement: if 20% of birthday facts appear exactly once, a calibrated model should get at least 20% of birthday facts wrong. They also note that hallucination is unavoidable only for a model straight out of pretraining: a system that answers a fixed set of questions from a database, and otherwise says “I don’t know”, would not make things up. Kandpal and colleagues count the documents in a training set that mention the entities of a question and relate that count to the model’s accuracy. Anthropic’s guide lists techniques for builders: permission to say “I don’t know”, quotes before answers, citations with retraction, restricting the model to the documents provided, and running the same prompt several times to compare, since inconsistent answers can point to a made-up one.

Skills this lesson builds

  • Explain why a fluent, confident answer is not evidence that it is true, and name the facts most at risk: rare names, exact numbers, dates, citations and recent events.
  • Write a prompt that supplies the source text, asks for quotes and allows “I don’t know”, and handle that answer in an app.
  • Check a quote or a citation by code: find the quote in the source, and open or look up the cited work.
  • Compute a smoothed next-word estimate by hand and in code, and read what a confidence bar does to the answers.
  • Build a small known-answer test and report right, wrong and “I don’t know” counts.

Portfolio project: build a command-line tool that asks any chat model 30 questions with known answers, half about common facts and half about rare ones, in two set-ups: with no help, and with the source text in the prompt and “I don’t know” allowed. It prints a table of right, wrong and “I don’t know” counts for each set-up. Publish the questions, the scoring rule and the table.

Check yourself

  1. A chatbot gives you a study title, authors and a year, and you cannot find the study anywhere. How can that happen, and what do you do before using it?

    Answer

    The model writes the likely next words. Where the training text says little, such as a rare title or an exact citation, a smooth, well-formatted continuation is still likely and can be wrong, and nothing in the basic design checks it against a source. Open the cited work, or give the model the real source text and ask it to quote from it.

  2. You turn the temperature to its lowest value to stop invented answers. What changes, and what does not?

    Answer

    The answers become more repeatable, because the model takes its top choice almost every time. They do not become truer: the top choice is taken whether or not it is true, and a fact the model gave no chance still gets none. Giving it the source text, allowing “I don’t know” and checking the answers against known ones help instead.

  3. A word is followed by 9 words in all in the text, 3 of them different, and one of the three followed it 3 times. The text has 50 different words, and only this one-word context applies. With the toy’s rule, what is the chance of the word that followed it 3 times?

    Answer

    Trust is seen / (seen + kinds) = 9 / (9 + 3) = 0.75. The chance is 0.75 x 3/9 + 0.25 x 1/50 = 0.25 + 0.005 = 0.255.

  4. With a bar of 80%, after 3 added copies the toy has 76.5% on Lima. What does it say, and how many copies does it need?

    Answer

    It says “I don’t know”, because its top word, Lima, is below the bar. It needs 4 copies, where Lima has 81.5%.

  5. At temperature 1 and a 50% bar the toy says “I don’t know” to the Peru question, but at 0.3 it says Paris. Why, and why does Lima not rise?

    Answer

    A lower temperature sharpens the chances, so Paris goes from 10.5% to 57.4% and passes the bar, which is checked after the temperature is applied. Lima does not rise because its chance is 0, and 0 to any power is still 0.

Further reading

Gentle

Deep

Optional

Code

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

corpus.txt37 linesDownload corpus.txt
Show code
The capital of France is Paris.
The capital of France is Paris.
The capital of France is Paris.
The capital of France is Paris.
The capital of France is Paris.
The capital of Japan is Tokyo.
The capital of Japan is Tokyo.
The capital of Japan is Tokyo.
The capital of Italy is Rome.
The capital of Italy is Rome.
The capital of Italy is Rome.
The capital of Spain is Madrid.
The capital of Spain is Madrid.
The capital of Germany is Berlin.
The capital of Germany is Berlin.
The capital of Egypt is Cairo.
The capital of Egypt is Cairo.
The capital of Canada is Ottawa.
The capital of Canada is Ottawa.
The capital of Greece is Athens.
The capital of Norway is Oslo.
The capital of Portugal is Lisbon.
The capital of Austria is Vienna.
The capital of Cuba is Havana.
The capital of Chile is Santiago.
The capital of Poland is Warsaw.
The capital of Turkey is Ankara.
The capital of Kenya is Nairobi.
The capital of Australia is Canberra.
The capital of China is Beijing.
Paris stands on the river Seine.
Tokyo is a very large city.
Rome has many old ruins.
Llamas live in the mountains of Peru.
Many people visit Peru to walk to Machu Picchu.
The ferry to Malta leaves from Sicily.
Laos lies between Thailand and Vietnam.
embed_widget.py35 linesDownload embed_widget.py
Show code
"""Keep the copy of the corpus and the questions inside ../widget.html equal to the ones here.

    python3 embed_widget.py          rewrite the two data blocks 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

from prompts import PROMPTS
from toy_lm import load_corpus

WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CORPUS_BLOCK = re.compile(r'(<script type="text/plain" id="wmu-corpus">)(.*?)(</script>)', re.S)
PROMPTS_BLOCK = re.compile(r'(<script type="application/json" id="wmu-prompts">)(.*?)(</script>)', re.S)


def embed(html):
    """html with both data blocks rewritten from corpus.txt and prompts.py."""
    corpus = load_corpus()
    if "</script" in corpus.lower() or "<!--" in corpus:
        raise ValueError("the corpus would end or confuse the script element")
    prompts = json.dumps(PROMPTS, ensure_ascii=False, indent=1)
    html = CORPUS_BLOCK.sub(lambda m: m.group(1) + corpus + m.group(3), html, count=1)
    return PROMPTS_BLOCK.sub(lambda m: m.group(1) + "\n" + prompts + "\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 blocks now match corpus.txt and prompts.py")
prompts.py13 linesDownload prompts.py
Show code
"""The three questions the widget offers. None of the three capitals is in corpus.txt.

The true sentence is what the corpus would need to say for the toy to know the answer.
"""
PROMPTS = [
    {"name": "Peru", "prompt": "The capital of Peru is", "truth": "Lima"},
    {"name": "Laos", "prompt": "The capital of Laos is", "truth": "Vientiane"},
    {"name": "Malta", "prompt": "The capital of Malta is", "truth": "Valletta"},
]


def true_sentence(item):
    return f"{item['prompt']} {item['truth']}."
show_numbers.py78 linesDownload show_numbers.py
Show code
"""Print every number the lesson quotes. Run it from this folder: python3 show_numbers.py"""
from prompts import PROMPTS, true_sentence
from toy_lm import (IDK, answer, lines_of, load_corpus, model_with_extra, next_word_probs, ranked, smallest_k,
                    tokenize, train, with_temperature)

corpus = load_corpus()
lines = lines_of(corpus)
model = train(lines)
peru = PROMPTS[0]
PROMPT, TRUTH, SENTENCE = peru["prompt"], peru["truth"], true_sentence(peru)


def table(probs, n=5):
    return ", ".join(f"{w} {p:.1%}" for w, p in ranked(probs)[:n])


print("== The corpus (corpus.txt)")
capitals = [l for l in lines if l.startswith("The capital of")]
print(f"  {len(lines)} sentences, {sum(len(tokenize(l)) for l in lines)} tokens, {len(model['vocab'])} different words")
print(f"  {len(capitals)} are 'The capital of X is Y.' sentences about {len(set(capitals))} countries")
for country in ("France", "Japan", "Italy"):
    print(f"    The capital of {country} appears {sum(1 for l in lines if country in l and l.startswith('The capital'))} times")
print(f"  words after 'is': {sum(model['singles'][('is',)].values())} in all, {len(model['singles'][('is',)])} different")

print(f"\n== Prompt: {PROMPT!r}   (true word: {TRUTH})")
for item in PROMPTS:
    print(f"  {item['name']}: true word {item['truth']!r} in the corpus: {item['truth'] in model['vocab']}; "
          f"{item['prompt'].split()[-2]!r} in the corpus: {item['prompt'].split()[-2] in model['vocab']}")
probs = next_word_probs(model, PROMPT)
print(f"  probabilities add up to {sum(probs.values()):.12f}")
print(f"  top five: {table(probs)}")
print(f"  {TRUTH}: {probs.get(TRUTH, 0):.1%}")
seen = sum(model["singles"][("is",)].values())
kinds = len(model["singles"][("is",)])
trust = seen / (seen + kinds)
print(f"  by hand for Paris: trust {seen}/({seen}+{kinds}) = {trust:.2f}; "
      f"{trust:.2f} * 5/{seen} + {1 - trust:.2f} / {len(model['vocab'])} = {trust * 5 / seen + (1 - trust) / len(model['vocab']):.4f}")
print(f"  ('Peru', 'is') seen in the corpus: {('Peru', 'is') in model['pairs']}")
print(f"  flat guess 1/{len(model['vocab'])} = {1 / len(model['vocab']):.4f}")

one = model_with_extra(lines, SENTENCE, 1)
seen_is, kinds_is = sum(one["singles"][("is",)].values()), len(one["singles"][("is",)])
trust_is, trust_pair = seen_is / (seen_is + kinds_is), 1 / (1 + 1)
lower = trust_is * 1 / seen_is + (1 - trust_is) / len(one["vocab"])
print(f"  by hand for {TRUTH} after k = 1: 'is' now has {seen_is} followers, {kinds_is} different, trust {seen_is}/({seen_is}+{kinds_is}) = {trust_is:.4f}; "
      f"one-word estimate {trust_is:.4f} * 1/{seen_is} + {1 - trust_is:.4f} / {len(one['vocab'])} = {lower:.4f}; "
      f"('Peru', 'is') has 1 follower, 1 different, trust 1/(1+1) = {trust_pair}; "
      f"{trust_pair} * 1 + {1 - trust_pair} * {lower:.4f} = {trust_pair + (1 - trust_pair) * lower:.4f}")

print(f"\n== Adding k copies of {SENTENCE!r}")
for k in range(0, 9):
    probs = next_word_probs(model_with_extra(lines, SENTENCE, k), PROMPT)
    word, p = ranked(probs)[0]
    print(f"  k={k}: top {word} {p:.1%};  {TRUTH} {probs.get(TRUTH, 0):.1%};  runner-up {ranked(probs)[1][0]} {ranked(probs)[1][1]:.1%}")

print("\n== Smallest k")
for thr in (0.0, 0.5, 0.8, 0.9):
    print(f"  top word is {TRUTH} and has at least {thr:.0%}: k = {smallest_k(lines, PROMPT, SENTENCE, TRUTH, thr)}")
print(f"  other questions, no threshold: " + ", ".join(
    f"{i['name']} k = {smallest_k(lines, i['prompt'], true_sentence(i), i['truth'])}" for i in PROMPTS))

print("\n== A fact the corpus states 5 times")
probs = next_word_probs(model, "The capital of France is")
print(f"  top: {table(probs, 3)}")

print("\n== 'I don't know' below a threshold, k = 0")
probs = next_word_probs(model, PROMPT)
for thr in (0.05, 0.10, 0.11, 0.30, 0.50):
    print(f"  threshold {thr:.0%}: {answer(probs, thr)}")
print(f"  threshold 50%, k = 1: {answer(next_word_probs(model_with_extra(lines, SENTENCE, 1), PROMPT), 0.5)}")
print(f"  threshold 50%, France: {answer(next_word_probs(model, 'The capital of France is'), 0.5)}")

print("\n== Temperature, k = 0")
base = next_word_probs(model, PROMPT)
for t in (0.1, 0.2, 0.3, 0.5, 1.0, 1.5, 2.0):
    scaled = with_temperature(base, t)
    word, p = ranked(scaled)[0]
    print(f"  T={t}: top {word} {p:.1%}; {TRUTH} {scaled.get(TRUTH, 0):.1%}; at a 50% threshold the toy says: {answer(scaled, 0.5)}")
toy_lm.py95 linesDownload toy_lm.py
Show code
"""A toy language model that only counts which words follow which, using nothing but the standard library.

widget.html runs the same arithmetic in JavaScript; test_widget.py checks that the two agree.

The model reads the last two words of a prompt and asks three questions in turn, from most specific to
least: what followed these two words in the corpus, what followed the last word, and (when nothing at all
has been seen) any word of the corpus at random. The three answers are blended with the Witten-Bell
rule: the more different words have followed a context, the less that context is trusted.
"""
import re
from pathlib import Path

CORPUS = Path(__file__).resolve().parent / "corpus.txt"
START = "<s>"
TOKEN = re.compile(r"[A-Za-z0-9']+|[.,!?]")      # ASCII only, so Python and JavaScript split alike
IDK = "I don't know"


def load_corpus():
    return CORPUS.read_text(encoding="utf-8")


def tokenize(text):
    """Words and the marks . , ! ? as separate tokens. Capital letters count: 'Peru' is not 'peru'."""
    return TOKEN.findall(text)


def lines_of(text):
    """The non-empty lines of a corpus. Each line is one sentence."""
    return [line for line in text.split("\n") if line.strip()]


def train(lines):
    """Count what follows each pair of words and each single word. Every line starts after two START marks."""
    pairs, singles, vocab = {}, {}, set()
    for line in lines:
        words = [START, START] + tokenize(line)
        vocab.update(words[2:])
        for i in range(2, len(words)):
            for table, context in ((pairs, tuple(words[i - 2:i])), (singles, (words[i - 1],))):
                followers = table.setdefault(context, {})
                followers[words[i]] = followers.get(words[i], 0) + 1
    return {"pairs": pairs, "singles": singles, "vocab": sorted(vocab)}


def next_word_probs(model, prompt):
    """Probability of every word of the corpus coming next after the prompt. They add up to 1."""
    words = [START, START] + tokenize(prompt)
    vocab = model["vocab"]
    if not vocab:
        return {}
    probs = {w: 1 / len(vocab) for w in vocab}
    for table, context in ((model["singles"], tuple(words[-1:])), (model["pairs"], tuple(words[-2:]))):
        followers = table.get(context)
        if not followers:
            continue
        seen = sum(followers.values())
        trust = seen / (seen + len(followers))      # Witten-Bell: seen / (seen + kinds of follower)
        probs = {w: trust * followers.get(w, 0) / seen + (1 - trust) * probs[w] for w in vocab}
    return probs


def with_temperature(probs, temperature):
    """Raise every probability to 1/temperature and scale back to total 1. Below 1 it sharpens, above 1 it flattens."""
    powered = {w: p ** (1 / temperature) for w, p in probs.items()}
    total = sum(powered.values())
    return {w: p / total for w, p in powered.items()}


def ranked(probs):
    """(word, probability) from most to least likely. A tie goes to the word that sorts first."""
    return sorted(probs.items(), key=lambda item: (-item[1], item[0]))


def answer(probs, threshold=0.0):
    """The most likely word, or IDK when even that word has a probability below the threshold."""
    if not probs:
        return IDK
    word, p = ranked(probs)[0]
    return word if p >= threshold else IDK


def model_with_extra(lines, true_sentence, k):
    """The corpus with k more copies of one true sentence."""
    return train(lines + [true_sentence] * k)


def smallest_k(lines, prompt, true_sentence, truth, threshold=0.0, temperature=1.0, limit=50):
    """The fewest added copies of the true sentence after which the model answers `truth`, being at least
    `threshold` sure of it. None when `limit` copies are not enough."""
    for k in range(limit + 1):
        probs = with_temperature(next_word_probs(model_with_extra(lines, true_sentence, k), prompt), temperature)
        if answer(probs, threshold) == truth:
            return k
    return None
test_toy_lm.py139 linesDownload test_toy_lm.py
Show code
import pytest

from prompts import PROMPTS, true_sentence
from toy_lm import (IDK, answer, lines_of, load_corpus, model_with_extra, next_word_probs, ranked, smallest_k,
                    tokenize, train, with_temperature)

LINES = lines_of(load_corpus())
PERU = PROMPTS[0]
SENTENCE = true_sentence(PERU)


def probs_at(k, prompt=PERU["prompt"]):
    return next_word_probs(model_with_extra(LINES, SENTENCE, k), prompt)


@pytest.mark.parametrize("prompt", ["The capital of Peru is", "The capital of France is", "", "Peru", "zzz qqq", "is", "The capital of"])
@pytest.mark.parametrize("k", [0, 1, 7])
def test_probabilities_add_up_to_one(prompt, k):
    """Guards a backoff that leaks or double-counts probability (an unseen context, a one-word prompt, an empty one)."""
    probs = probs_at(k, prompt)
    assert sum(probs.values()) == pytest.approx(1, abs=1e-12)
    assert all(p >= 0 for p in probs.values())


@pytest.mark.parametrize("temperature", [0.1, 0.5, 1.0, 2.0])
def test_temperature_keeps_the_total_at_one(temperature):
    assert sum(with_temperature(probs_at(0), temperature).values()) == pytest.approx(1, abs=1e-12)


def test_thin_corpus_gives_a_fluent_wrong_answer():
    """The corpus never says what the capital of Peru, Laos or Malta is, yet the toy completes the sentence with
    a capital it has read about, and gives the true word no probability at all."""
    model = train(LINES)
    for item in PROMPTS:
        probs = next_word_probs(model, item["prompt"])
        top, p = ranked(probs)[0]
        assert top == "Paris" and top != item["truth"]
        assert item["truth"] not in model["vocab"] and probs.get(item["truth"], 0) == 0
        assert p == pytest.approx(0.1049, abs=5e-5)
        assert answer(probs) == "Paris"


def test_the_held_out_facts_are_not_in_the_corpus_in_any_wording():
    """Guards a corpus edit that quietly teaches the answer, which would make the demonstration dishonest."""
    text = load_corpus()
    for item in PROMPTS:
        assert item["truth"] not in text
        assert f"{item['name']} is" not in text            # no pair like ('Peru', 'is') to match the prompt
    assert ("Peru", "is") not in train(LINES)["pairs"]


def test_top_probability_matches_a_hand_calculation():
    """'is' is followed by 31 words, 19 of them different (30 capitals in 18 kinds, plus 'a' once). The trust in that
    context is 31/(31+19) = 0.62. Paris follows 'is' 5 times and the vocabulary has 77 words."""
    expected = 0.62 * 5 / 31 + 0.38 / 77
    assert next_word_probs(train(LINES), PERU["prompt"])["Paris"] == pytest.approx(expected, abs=1e-12)


def test_one_true_sentence_hand_calculation():
    """After one copy the pair ('Peru', 'is') was seen once, so its trust is 1/(1+1) = 0.5. The word 'is' now has 32
    followers, 20 of them different, so its trust is 32/52; Lima follows it once, and the vocabulary has 78 words."""
    single = (32 / 52) * (1 / 32) + (20 / 52) * (1 / 78)
    assert probs_at(1)["Lima"] == pytest.approx(0.5 + 0.5 * single, abs=1e-12)


def test_true_examples_take_over_and_the_code_finds_the_smallest_k():
    assert [ranked(probs_at(k))[0][0] for k in range(0, 4)] == ["Paris", "Lima", "Lima", "Lima"]
    assert smallest_k(LINES, PERU["prompt"], SENTENCE, PERU["truth"]) == 1
    for item in PROMPTS:
        assert smallest_k(LINES, item["prompt"], true_sentence(item), item["truth"]) == 1
    # Lima's share climbs with every copy
    shares = [probs_at(k)["Lima"] for k in range(1, 12)]
    assert shares == sorted(shares) and shares[0] == pytest.approx(0.5121, abs=5e-5)


@pytest.mark.parametrize("threshold, k", [(0.5, 1), (0.8, 4), (0.9, 8)])
def test_smallest_k_for_a_confidence_target(threshold, k):
    """Guards an off-by-one in the search: k-1 copies must fall short and k copies must reach the target."""
    assert smallest_k(LINES, PERU["prompt"], SENTENCE, PERU["truth"], threshold) == k
    assert ranked(probs_at(k))[0][1] >= threshold > ranked(probs_at(k - 1))[0][1]


def test_smallest_k_gives_up_at_the_limit():
    """Guards an off-by-one at the limit: 4 copies are needed at the 80% bar, so limit=4 finds them and limit=3 does not."""
    assert smallest_k(LINES, PERU["prompt"], SENTENCE, PERU["truth"], 0.8, limit=4) == 4
    assert smallest_k(LINES, PERU["prompt"], SENTENCE, PERU["truth"], 0.8, limit=3) is None


def test_abstaining_when_the_top_word_is_below_the_threshold():
    probs = probs_at(0)
    assert answer(probs, 0.5) == IDK
    assert answer(probs, 0.11) == IDK
    assert answer(probs, 0.10) == "Paris"                  # 10.5% clears a 10% bar: a low bar does not help
    assert answer(probs_at(1), 0.5) == "Lima"              # the same bar lets the right answer through
    assert answer(next_word_probs(train(LINES), "The capital of France is"), 0.5) == "Paris"   # and a well-read fact


def test_the_toy_gets_a_fact_it_has_read_often_right():
    """The control: the corpus states France's capital five times, so the same code answers it with 85%."""
    probs = next_word_probs(train(LINES), "The capital of France is")
    assert ranked(probs)[0][0] == "Paris" and ranked(probs)[0][1] == pytest.approx(0.8508, abs=5e-5)


def test_lower_temperature_makes_the_wrong_answer_surer_not_true():
    base = probs_at(0)
    shares = []
    for t in (2.0, 1.0, 0.5, 0.3, 0.1):
        scaled = with_temperature(base, t)
        top, p = ranked(scaled)[0]
        assert top == "Paris" and scaled.get("Lima", 0) == 0
        shares.append(p)
    assert shares == sorted(shares) and shares[-1] > 0.98
    # a 50% bar that makes the toy say "I don't know" at temperature 1 lets Paris through at 0.3
    assert answer(with_temperature(base, 1.0), 0.5) == IDK
    assert answer(with_temperature(base, 0.3), 0.5) == "Paris"


def test_temperature_one_changes_nothing():
    base = probs_at(0)
    scaled = with_temperature(base, 1.0)
    assert all(scaled[w] == pytest.approx(base[w], abs=1e-15) for w in base)


def test_a_tie_goes_to_the_word_that_sorts_first_whatever_the_corpus_order():
    """Guards the vocabulary order that ties rely on: it is sorted, so equal words rank alphabetically in either corpus order."""
    for lines in (["x b", "x a"], ["x a", "x b"]):
        probs = next_word_probs(train(lines), "x")
        assert probs["a"] == probs["b"]
        assert [w for w, _ in ranked(probs)] == ["a", "b", "x"]


def test_an_empty_corpus_has_nothing_to_say():
    assert next_word_probs(train([]), "anything") == {}
    assert answer({}, 0.0) == IDK


def test_tokenizer_keeps_ascii_words_and_marks_only():
    assert tokenize("Peru's capital, Lima! Café 2026?") == ["Peru's", "capital", ",", "Lima", "!", "Caf", "2026", "?"]
    assert lines_of("a\n\n  \nb\n") == ["a", "b"]
test_widget.py122 linesDownload test_widget.py
Show code
"""Guards for ../widget.html: its data blocks, its size, and that its model gives the numbers toy_lm.py gives."""
import json
import re
import subprocess

import embed_widget
from embed_widget import WIDGET, embed
from prompts import PROMPTS
from toy_lm import (answer, lines_of, load_corpus, model_with_extra, next_word_probs, ranked, smallest_k, tokenize, train,
                    with_temperature)

HTML = WIDGET.read_text(encoding="utf-8")

# Reads the model out of the widget between its marker comments, runs it with Node, and compares with the
# numbers Python wrote into the fixture: the same words, the same order, probabilities within 1e-12.
PARITY = r"""
import fs from 'node:fs';
const [fixture, widget] = process.argv.slice(2);
const fx = JSON.parse(fs.readFileSync(fixture, 'utf8'));
const html = fs.readFileSync(widget, 'utf8');
const js = html.slice(html.indexOf('/* ---------- the toy model'), html.indexOf('/* ---------- state'));
const api = new Function("'use strict';" + js + '; return {tokenize, linesOf, train, nextWordProbs, withTemperature, ranked, answer, smallestK};')();
const bad = [];
let checks = 0;
fx.tokens.forEach((t) => { checks++; if (JSON.stringify(api.tokenize(t[0])) !== JSON.stringify(t[1])) bad.push(['tokenize', t[0]]); });
fx.cases.forEach((c, i) => {
  const lines = api.linesOf(c.corpus);
  const model = api.train(lines);
  checks++;
  if (JSON.stringify(model.vocab) !== JSON.stringify(c.vocab)) { bad.push(['vocab', i]); return; }
  const probs = api.withTemperature(api.nextWordProbs(api.train(lines.concat(Array(c.k).fill(c.sentence))), c.prompt), c.temp);
  checks++;
  if (probs.size !== c.probs.length) { bad.push(['size', i]); return; }
  c.probs.forEach(([w, p]) => { if (!(Math.abs(probs.get(w) - p) <= 1e-12)) bad.push(['prob', i, w, probs.get(w), p]); });
  checks++;
  if (JSON.stringify(api.ranked(probs).map((e) => e[0])) !== JSON.stringify(c.ranked)) bad.push(['order', i]);
  checks++;
  if (api.answer(probs, c.threshold) !== c.answer) bad.push(['answer', i, api.answer(probs, c.threshold), c.answer]);
  if (c.truth !== null) {
    checks++;
    const k = api.smallestK(lines, c.prompt, c.sentence, c.truth, c.threshold, c.temp, 12);
    if (k !== c.smallest) bad.push(['smallest k', i, k, c.smallest]);
  }
});
console.log(JSON.stringify({ checks, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""

# Corpora a reader might paste: words that are also JavaScript object keys, non-ASCII text, an empty corpus.
CORPORA = {
    "lesson": load_corpus(),
    "odd words": "constructor is __proto__ .\nhasOwnProperty is toString !\nThe café is open, isn't it?\nNo. 2026 is 2026.\n\n\n",
    "one line": "The capital of Peru is Lima.\n",
    "empty": "",
    "one word": "x\n",                     # its only word has probability exactly 1, the edge of the answer-or-abstain check
}
PROMPT_CASES = [(p["prompt"], p["truth"]) for p in PROMPTS] + [
    ("The capital of France is", "Paris"), ("", "The"), ("constructor", "is"), ("the capital of peru is", "Lima"),
    ("is", "a"), ("zzz qqq", "Lima"), ("Café is", "open"),
]
TOKEN_CASES = ["Peru's capital, Lima! Café 2026?", "", "  \t ", "a_b-c", "éè", "end.", "x" * 40, "A.B,C!D?E"]


def test_widget_data_blocks_match_corpus_and_prompts():
    """Guards a corpus or question edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_embed_rewrites_a_widget_whose_data_blocks_drifted():
    """Guards embed() skipping a block: a hand-edited corpus line or question must be put back."""
    drifted = HTML.replace("Tokyo.", "Kyoto.", 1).replace('"Lima"', '"Lime"', 1)
    assert drifted != HTML and embed(drifted) == HTML


def test_every_element_the_script_looks_up_exists():
    """Guards a widget that never starts: the parity harness runs only the model, so a misspelt id passed here
    and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script))
    assert lookups and all(f'id="{i}"' in HTML for i in lookups), sorted(i for i in lookups if f'id="{i}"' not in HTML)


def test_corpus_is_safe_to_embed_in_a_script_element():
    corpus = load_corpus()
    assert "\r" not in corpus and "</script" not in corpus.lower() and "<!--" not in corpus


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.encode("utf-8")) < 120 * 1024
    page = embed_widget.CORPUS_BLOCK.sub("", embed_widget.PROMPTS_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


def test_widget_model_gives_the_numbers_of_the_python_code(tmp_path):
    """Guards the widget's JavaScript drifting from toy_lm.py: the lesson prints Python's numbers and the
    reader sees the widget's, so the two must agree for corpora with odd words, unseen prompts and every setting."""
    cases = []
    for name, corpus in CORPORA.items():
        lines = lines_of(corpus)
        vocab = train(lines)["vocab"]
        for prompt, truth in PROMPT_CASES:
            sentence = f"{prompt} {truth}."
            for k in (0, 1, 3):
                for temp in (0.3, 1.0, 1.7):
                    probs = with_temperature(next_word_probs(model_with_extra(lines, sentence, k), prompt), temp)
                    for threshold in (0.0, 0.5, 1.0):
                        cases.append({"name": name, "corpus": corpus, "prompt": prompt, "sentence": sentence, "truth": truth,
                                      "k": k, "temp": temp, "threshold": threshold, "vocab": vocab,
                                      "probs": [[w, p] for w, p in probs.items()], "ranked": [w for w, _ in ranked(probs)],
                                      "answer": answer(probs, threshold),
                                      "smallest": smallest_k(lines, prompt, sentence, truth, threshold, temp, limit=12)})
    fixture = {"tokens": [[t, tokenize(t)] for t in TOKEN_CASES], "cases": cases}
    (tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
    (tmp_path / "parity.mjs").write_text(PARITY, encoding="utf-8")
    run = subprocess.run(["node", str(tmp_path / "parity.mjs"), str(tmp_path / "fixture.json"), str(WIDGET)],
                         capture_output=True, text=True, timeout=120)
    assert run.returncode == 0, run.stdout + run.stderr
    result = json.loads(run.stdout)
    assert result["bad"] == [] and result["checks"] > 1000
    assert any(c["smallest"] == 1 for c in cases) and any(c["answer"] == "I don't know" for c in cases)