On this page
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.
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.
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.
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%.
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.
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
-
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.
-
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.
-
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.
-
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%.
-
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
- Reduce hallucinations (Anthropic documentation; read 2026-10-19)
- What are AI hallucinations? (IBM explainer; read 2026-10-19)
Deep
- Why Language Models Hallucinate (Kalai, Nachum, Vempala and Zhang, 2025; read 2026-10-19)
- Large Language Models Struggle to Learn Long-Tail Knowledge (Kandpal, Deng, Roberts, Wallace and Raffel, 2022; abstract read 2026-10-19)
Optional
Code
Each file runs on its own. Download it, or read it here.
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.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")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 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)}")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 NoneShow 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"]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)