What is prompt caching, and when does it save money?

Prompt caching reuses the work done on the start of a recent request. It saves money only when the same long start returns soon, with changing text last.

  • 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

Send a chatbot a 1,200-token report and a question, mark where the report ends, then send a second question about the same report a minute later. A token is a small piece of text, often a whole word. With this lesson’s example prices of $2.00 for every million tokens read normally and $0.10 for every million reused, the second request’s input, the text you send, costs $0.000160 instead of $0.002508: 94% less. Put a date line at the very top of the second request instead, and it costs 25% more than with no caching at all.

In one minute

Prompt caching means a service reuses the work it already did on the start of a recent request, so a new request that begins with exactly the same tokens costs less and gets its first word sooner. A service keeps a start only where you mark the end of the fixed part (providers call this a breakpoint) or where a request ends. A new request reuses a kept start only if it begins with all of it: one changed word before the mark loses the whole start. Think of a bookmark in a long document: change any page before it and you must read from the top again. So put fixed parts first, mark where they end, and put changing parts last. It saves money when the same long start comes back within a few minutes. Rules differ by provider; read your provider’s caching page.

Explore the numbers

Open the widget and press Date at the top, then Date at the end. The match line shows how many tokens are reused, and the cost line shows the effect on the bill. Untick the mark box and press New question to see a miss. Under More settings, set Earlier request was sent to 6 minutes ago, or shorten the Pasted document, and watch the saving shrink or vanish.

How it works

What the server keeps

To answer, a server reads your whole prompt. While it reads, it writes notes about every token, called the attention cache; Why does a chatbot forget? shows how big the notes grow. Normally the server drops them after the reply. With prompt caching it keeps them for a short time, so a later request that starts with the same tokens reuses the kept notes instead of reading those tokens again. That skips work, so the first word comes sooner, and providers bill the reused part at a lower price.

A stored start is reused whole or not at all

A service stores a start only at a mark, the point where you say the fixed part ends, or where a request ends. Nothing shorter is stored.

The server compares a new request with a stored start token by token, from the first token. The tokens they share at the start are called a prefix. The request reuses the stored start only if it begins with all of it. Everything up to its end is read from storage, and everything after is read again. If the request differs anywhere before that end, the stored start gives nothing, even when most of it matches.

Two new requests against a stored start that ends at a mark. The first begins with all four stored boxes, which are reused, and the rest is read again. The second changes one word before the mark, so nothing is reused.

Figure 1. A stored start ends at the mark. Green is reused, orange dashed is read again.

Why order matters

Say each request holds a date line, fixed instructions with a long document, and a question, and the mark sits after the document. If the date line comes first, the first token changes every time, so the stored start never matches and nothing is reused. If it comes last, the whole stored start matches. In the toy, a 1,272-token request with the date first reuses 0 tokens, and with the date last it reuses 1,236. The other 36, the question and the date, are read again.

A chat has the same shape. Each turn sends the whole chat again, so it begins with the whole previous request, stored where that request ended. In the ten-turn chat of Why is my AI app bill higher than I expected?, 14,400 of the 17,300 input tokens can be reused this way.

A prompt with the date line first is all orange, nothing reused. With the date line last, the instructions and document before the mark are green, reused, and the question and date are orange.

Figure 2. The same parts in two orders, mark after the document. Green is reused, orange dashed is read again.

Three prices

Input is billed at one of three prices. Text read normally costs the normal price. Text the service stores for the first time often costs a little more, called a write. Text reused from storage costs far less, called a read.

Three bars for a million tokens: normal $2.00, written to the cache $2.50, read from the cache $0.10.

Figure 3. The example prices used in this lesson. Providers set their own, and they change.

The extra for a write means caching can lose. A 1,200-token start used once costs $0.003000 with caching instead of $0.002400 without. Used twice, it costs $0.003120 instead of $0.004800. With these prices, a single reuse already repays the extra.

How long a stored start lasts

A service keeps a stored start for a set time, called its lifetime, and each use starts the clock again. In the toy’s five-minute setting, a start sent every minute stays stored. After six minutes without a use it is gone, and the next request writes it again and pays the extra. Many services also skip very short starts, because each has a minimum length.

What this toy leaves out

A busy service may drop a stored start early. Requests may reach different machines that do not share a copy. Images and tools in a prompt follow their own rules. The widget also splits text differently from a real model, and it shows input only; output is billed as usual.

What this means when you build

  • Put fixed text first. Instructions, examples and long documents go before anything that changes. The date, the user’s name and the new question go last. In the toy, one date line first cost 25% more than no caching, because nothing matched and the write extra was still paid.
  • Mark the end of the fixed part. With no mark, a service stores a start only where each request ends, so a new question after a shared document gets no hit. In the toy that costs 25% more than no caching.
  • Count on a return. Caching pays when the same start comes back within its lifetime. A start used once costs more.
  • Check the minimum. A start shorter than your provider’s minimum is not stored, so a short prompt gains nothing.
  • Read the usage numbers. Providers report how many input tokens were read from storage. If that is zero when you expected a hit, something before the mark differs.

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 every number below, and python3 -B -m pytest -q -p no:cacheprovider runs the tests.

The match in code

A prompt is a list of tokens, made with lesson 1’s tokenizer. shared_start counts leading tokens until the first difference, and reuse applies the rule of this lesson: a stored start is read whole when the new request begins with all of it, and not at all otherwise.

def shared_start(old, new) -> int:
    n = 0
    for a, b in zip(old, new):
        if a != b:
            break
        n += 1
    return n

def reuse(stored, new) -> int:
    return len(stored) if shared_start(stored, new) == len(stored) else 0

A rule then turns the reused count into three counts, tokens read, written and billed normally. These are the core lines of bill_request, comments left out. mark is how many leading tokens the caller asks to store, and None means the whole prompt, the automatic case:

read = reused if reused >= rule.minimum else 0
stored = total if mark is None else min(mark, total)
written = stored - read if stored >= rule.minimum and stored > read else 0
plain = total - read - written

The mark decides what a later request can reuse, not only what a write costs. The cache keeps stored tokens, so the next question reuses the fixed part only if the mark ended it. For a 1,200-token prompt of which 1,000 tokens are fixed, bill_request(A, 1200, 0, mark=1000) writes 1,000 tokens and bills the other 200 normally, and a later prompt that begins with those 1,000 tokens reads them. With mark=None the cache keeps all 1,200, and a later prompt with a new question after the same 1,000 tokens reuses nothing.

Four rules as parameters

Each provider rule is a few numbers: three prices per million tokens, a minimum length and a lifetime. The widget’s Rule setting offers these four. The prices are in dollars per million tokens.

Rule Normal Write Read
A 2 2.50 0.10
B 2 4 0.10
C 0.10 0.125 0.01
D 2 2.50 0.10

The minimum is in tokens.

Rule Minimum Lifetime
A 512 5 min
B 512 60 min
C 512 5 min
D 1,024 30 min

These are the prices for short prompts. Both providers charge more for very long ones: Haiku 5.5 above 100,000 tokens, gpt-6.1-sol above 272K.

Rules A to C come from Anthropic’s pricing and caching pages, read 2026-10-19. A is Sonnet 5.5 with the default five-minute copy, B is the same model with the one-hour option, and C is Haiku 5.5 with the five-minute copy. The caching page gives 512 tokens as the minimum for both models and says a shorter prompt is processed without caching and without an error. A copy lives five minutes, renewed at no extra cost on each use. The page says a write happens only at a breakpoint: marking a block with cache_control writes one entry, “a hash of the prefix ending at that block”, and no earlier position gets one. It then says “Cache hits require 100% identical prompt segments, including all text and images up to and including the block marked with cache control.” That is the whole-start rule of this lesson. In its automatic mode the breakpoint moves to the last cacheable block of each request, which is the toy’s unmarked case. The order is tools, then system prompt, then messages, and different organizations never share caches. A request may carry up to four breakpoints, and the page says the system looks back at most 20 blocks from each. Each response reports the tokens read and written.

Rule D comes from OpenAI’s pricing page, the gpt-6.1-sol row, and its caching guide, read 2026-10-19. Caching is on by default, the minimum is 1,024 tokens, and “Cache reuse requires the entire rendered prefix to match.” For GPT-5.6 and later, the guide’s table says a copy lasts at least 30 minutes after its latest write or reuse. It lists a write at “1.25× the uncached input-token rate” and a read at “0.1× uncached input (0.05× for GPT-6.1 Sol)”. For the gpt-6.1-sol row that is $2.50 to write and $0.10 to read against $2.00 for input, the same as the pricing page. The guide also describes the toy’s unmarked case: when a static message is followed by a changing one, the longer cached prefix does not match, and the remedy is “an explicit breakpoint after the static content”. Its example uses explicit-only mode “to avoid writing the dynamic content to cache”, which is how the toy’s marked case works; in implicit mode OpenAI “places a breakpoint at the end of the latest eligible message”, so the dynamic content would be written too. Its own lookup tries several boundaries, among them “the first 2 and latest 50 explicit breakpoints, the implicit breakpoint, up to 20 earlier eligible message endings”. It also warns that a request reuses a copy only if it reaches a machine holding a matching, unexpired entry, and it reports reuse in a cached_tokens field.

The break-even

Take a start of n tokens, with a normal price p, a write price w and a read price h per token. Used again r times, it costs:

without caching   (1 + r) n p
with caching      n w + r n h
pays off when     r >= (w - p) / (p - h)

That gives 0.26 reads for rule A, 1.05 for B, 0.28 for C and 0.26 for D. Reads come whole, so the answer is 1, 2, 1 and 1. Anthropic’s pricing page says the same: caching pays off after one read for the five-minute copy and after two for the one-hour copy. For a 1,200-token start on rule A, the total input cost after the number of requests sent in the first column is:

Sent Cached Uncached
1 $0.003000 $0.002400
2 $0.003120 $0.004800
3 $0.003240 $0.007200
5 $0.003480 $0.012000

Total cost against requests one to five. Without caching it climbs from $0.0024 to $0.0120. With caching it starts at $0.0030 and rises to $0.0035. The lines cross between one and two requests.

Figure 4. Cost of one 1,200-token start used again and again, rule A. Orange dashed is no caching, green is caching.

A ten-turn chat

Why is my AI app bill higher than I expected? builds a chat of 10 turns with a 500-token system prompt, 60-token messages and 200-token replies, and each turn sends the whole chat again. The prompts hold 560, 820, and so on up to 2,900 tokens, 17,300 in all. Without caching the input costs $0.0346 and the output $0.02, so $0.0546.

With rule A, no mark and one turn a minute, each request is stored where it ends, so turn 1 writes its 560 tokens. Every later turn reads the whole previous prompt and writes only the 260 new tokens. That reads 14,400 tokens and writes 2,900. The prompts hold 560 + 260k tokens for k from 0 to 9, so the reads are the first nine prompts, 14,400, and the writes are 560 plus nine times 260, 2,900. The input is 14,400 x $0.10 + 2,900 x $2.50 per million: $0.00144 + $0.00725 = $0.00869, 75% less. The total, output included, is $0.02869: 47% less, because output is billed as usual.

Four bars: no caching $0.0546; a turn a minute $0.0287, 47% less; turns six minutes apart $0.0633, 16% more; date line first $0.0633, 16% more.

Figure 5. The same chat under four conditions, rule A, output included.

Space the turns six minutes apart and each copy has expired, so every turn writes its whole prompt: 17,300 tokens at $2.50 per million is $0.04325 for the input, and the total is 16% more. A date line first does the same. The table gives each rule’s total against no caching, with the turns 1 minute apart and 40 minutes apart.

Rule 1 min 40 min
A 47% less 16% more
B 39% less 39% less
C 45% less 16% more
D 43% less 15% more

With a date line first, every rule loses: A 16% more, B 63% more, C 16% more, D 15% more. B pays its higher write price on every turn. D saves a little less than A because its first two prompts, 560 and 820 tokens, are under the 1,024-token minimum and are billed normally.

Time saved

A reused start is not read again, so the first word can come sooner. Using the best-case model of Why does an AI wait a moment before the first word?, a chip at its data-sheet speed with nothing measured, reading n tokens takes the larger of a math floor and a memory floor. The newest token is always read, so a fully reused prompt still costs one pass.

The table gives the tokens reused out of the prompt, then the first-word time in milliseconds before and after reuse.

Reused Before After
8,000 of 8,020 412.8 7.9
2,640 of 2,900 149.3 13.4
0 of 560 28.8 28.8

The first row is an 8,000-token document and a 20-token question. Its 20 new tokens are far under the 153-token ridge of that lesson, so the pass costs one decode step, 7.9 ms. The second row is the chat’s last turn, where 260 new tokens remain. Real services add network time and waiting in line, and the provider pages read for this lesson give no latency figure, so treat these as limits of the model, not promises.

A self-hosted server can reuse part of a start

A server running its own model is not bound by a provider’s rule, and vLLM reuses part of a start. Its automatic prefix caching keeps notes in blocks of tokens and hashes each block together with all the tokens before it, so a new request finds every leading block that matches, and a block is found only if everything before it matches too. Only full blocks are cached, and the least recently used are dropped first (vLLM documentation, read 2026-10-19). None of the four rules above works this way, and the toy does not model it. Why AI servers waste memory, and how paging fixes it shows the sharing.

How to order a prompt

Put these in this order, mark the end of the shared part (items 1 and 2), and keep that part identical, character for character. A changed space, a reordered list or a random ID counts as a difference.

  1. Fixed instructions and examples.
  2. Long documents that several requests share.
  3. The conversation so far.
  4. The new question.
  5. Lines that change every time, such as the date: last, or left out.

Checking a hit rate

After each response, compare what you expected with what the provider reports. Anthropic’s responses carry cache_read_input_tokens and cache_creation_input_tokens next to input_tokens, and OpenAI’s carry cached_tokens; each page says which counts include which. A hit rate is the share of input read from storage:

hit_rate = read / (read + written + plain)     # 14,400 / 17,300 = 83% for the chat above

A rate near zero when you expected a high one means the start differs earlier than you think. Log it per request, not as one daily average, so a change that adds a date line stands out.

Proof it works

test_prefix_cache.py has 27 tests. The shared start is compared with Python’s os.path.commonprefix, which would catch a count that kept going after a difference. A stored start is read whole or not at all, and a request that shares most of one reads nothing from it. Bills are checked by hand. A copy hits at minute 5 and misses at minute 6, and requests every 4 minutes keep it alive. The break-even for each rule is checked against a brute-force run of the simulation. With caching off, the chat gives lesson 7’s $0.0546, with it on, the closed form above, and each rule’s chat total is pinned at 1 minute, 40 minutes and with the date first. test_widget.py has 9 tests. It runs the widget’s JavaScript in Node against the Python for 800 combinations of settings, with the mark box ticked and unticked, and drives the page through a small stand-in for the browser. All 36 tests run in about 3 seconds.

Where this is used in practice

Anthropic’s and OpenAI’s pages above are the sources for rules A to D. OpenAI’s guide says caching can discount input by up to 95%; that is its figure, and a read at 0.05 times the normal price gives that number. SGLang (Zheng et al., 2023, body read 2026-10-19) keeps cached prefixes in a radix tree, described in its section 3 as RadixAttention, and its authors report up to 6.4 times the throughput on their tests, a figure for whole systems and not this lesson’s bills.

Skills this lesson builds

  • Explain why a request reuses a stored start only whole, and why one changed token before the mark voids it.
  • Order a prompt so the fixed part comes first, and mark where it ends.
  • Work out the break-even number of reads from a write price and a read price.
  • Compare a chat’s bill with and without caching, including the cases where caching costs more.
  • Check from a response’s usage numbers how many input tokens were read from storage.

Portfolio project: pick one provider that offers caching. Write a script that sends a 3,000-token fixed document with ten different questions, once with a date line first and once with it last, a minute apart. Record the tokens read from storage and the cost of each request, using the provider’s price page. Publish the script, a results table and a note on where the bill differs from the formula in this lesson.

Check yourself

  1. An app starts every request with “Today is” and the current time, then 2,000 tokens of fixed instructions. Caching saves nothing. Why, and what is the fix?

    Answer

    The first tokens change every time, and a request reuses a stored start only if it begins with all of it, so nothing is reused. Move the date line after the instructions, next to the question, and mark the end of the instructions.

  2. In the toy’s five-minute setting, a start is sent at minutes 0, 4, 8 and 12. Another is sent at minutes 0, 6 and 12. Which one is read from storage?

    Answer

    The first. Each gap is 4 minutes, under 5, and each use starts the clock again, so minutes 4, 8 and 12 are reads. The second has gaps of 6 minutes, so each copy expires before the next request and every request writes again.

  3. On rule A, a 1,200-token start is used in 3 requests within its lifetime. What does it cost with and without caching?

    Answer

    With caching: 1,200 x $2.50 per million is $0.003000 for the write, plus 2 reads of 1,200 x $0.10 per million, $0.000240, so $0.003240. Without: 3 x 1,200 x $2.00 per million is $0.007200.

  4. On rule B, with a write price of $4.00, how many reads does a start need to repay its write?

    Answer

    The formula gives (4 - 2) / (2 - 0.10) = 1.05, so 2 reads. Check per million tokens: with 1 read it is $4.10 against $4.00 without, still behind; with 2 reads it is $4.20 against $6.00.

  5. In the ten-turn chat on rule A, the input falls 75% but the total bill only 47%. Why?

    Answer

    Caching changes only the input price. The output, $0.02, is billed as usual: the total falls from $0.0546 to $0.02869, while the input alone falls from $0.0346 to $0.00869.

Further reading

Gentle

Deep

Optional

Code

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

embed_widget.py40 linesDownload embed_widget.py
Show code
"""Keep the copy of the tokenizer, the rules and the example texts inside ../widget.html equal to the ones in prefix_cache.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 prefix_cache as sim

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


def config():
    """What the page needs to run the same match and the same sums: lesson 1's learned merges, the four rules, the settings and the texts."""
    return {
        "merges": [list(pair) for pair in sim.merges()], "marker": sim.MARKER, "docBase": sim.DOC_BASE,
        "rules": [{"key": r.key, "summary": sim.summary(r), "price": float(r.price), "write": float(r.write), "read": float(r.read),
                   "minimum": r.minimum, "lifetime": r.lifetime} for r in sim.RULES],
        "docs": sim.DOC_LENGTHS, "defaultDoc": sim.DEFAULT_DOC, "ages": sim.AGES, "defaultAge": sim.DEFAULT_AGE,
        "earlier": sim.EARLIER, "presets": sim.PRESETS,
    }


def embed(html):
    """html with the data block rewritten from prefix_cache.py."""
    settings = json.dumps(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 prefix_cache.py")
prefix_cache.py313 linesDownload prefix_cache.py
Show code
"""Prompt caching as a small simulation: which start of a prompt is reused, and what that does to the bill.

Python 3 standard library only (plus three earlier lessons' code, imported, not copied). Run `python3 show_numbers.py`
to print every number the lesson quotes.

The model, in four rules:
  1. A request's prompt is a list of tokens (lesson 1's tokenizer, `bpe.encode`).
  2. The provider keeps a start of a request only up to a mark the caller set, or, with no mark, to the end of the request.
     A new request reuses a kept start only if it begins with the whole of it; it reads all of that start and nothing
     shorter, so one changed token anywhere inside the kept start loses all of it.
  3. A rule (minimum length, lifetime of an unused copy, three prices) decides what each token costs: tokens read from
     a kept start, tokens written to a new one, tokens neither.
  4. A kept start is refreshed each time a request reads it and is gone once it has gone unused for the lifetime.

Prices are dollars per million tokens. Money is counted in whole units of 1/1000 of a micro-dollar per token
(a price of $2 per million is 2,000 units per token), so every bill is an exact integer; the widget does the same sums.
The rules and prices below are read from provider pages dated 2026-10-19 (see the lesson's Further reading). They change.
"""
from __future__ import annotations

import functools
import math
import sys
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from pathlib import Path

LESSONS = Path(__file__).resolve().parents[2]
for _lesson in ("what-is-a-token", "why-ai-bill-is-high", "why-ai-waits-before-the-first-word"):
    sys.path.append(str(LESSONS / _lesson / "code"))     # appended, so this folder's own modules win a name clash
import bill as chat_bill                                  # lesson 7: the chat and its bill without caching
import bpe                                                # lesson 1: the tokenizer
import prefill_sim                                        # lesson 23: the best-case time of reading a prompt

UNITS = 1000                    # units per (dollar per million tokens)


# --- the rules -----------------------------------------------------------------------------------

@dataclass(frozen=True)
class Rule:
    key: str                    # the letter the widget and the lesson use
    label: str                  # who published it
    price: Decimal              # normal input price, dollars per million tokens
    write: Decimal              # price of a token written to the copy
    read: Decimal               # price of a token read from the copy
    output: Decimal             # output price, dollars per million tokens
    minimum: int                # the cached start must be at least this many tokens
    lifetime: int               # minutes an unused copy lives; every read starts the count again

    def units(self, kind: str) -> int:
        return int(getattr(self, kind) * UNITS)


D = Decimal
# Anthropic: platform.claude.com/docs/en/about-claude/pricing (prices) and .../build-with-claude/prompt-caching (minimum, lifetime). Read 2026-10-19.
# OpenAI: developers.openai.com/api/docs/pricing (prices) and .../guides/prompt-caching (minimum, lifetime). Read 2026-10-19.
RULES = [
    Rule("A", "Anthropic, Sonnet 5.5, 5-minute cache", D("2"), D("2.50"), D("0.10"), D("10"), minimum=512, lifetime=5),
    Rule("B", "Anthropic, Sonnet 5.5, 1-hour cache", D("2"), D("4"), D("0.10"), D("10"), minimum=512, lifetime=60),
    Rule("C", "Anthropic, Haiku 5.5, 5-minute cache", D("0.10"), D("0.125"), D("0.01"), D("0.50"), minimum=512, lifetime=5),
    Rule("D", "OpenAI, gpt-6.1-sol", D("2.00"), D("2.50"), D("0.10"), D("10"), minimum=1024, lifetime=30),
]
RULE_BY_KEY = {r.key: r for r in RULES}


def break_even_reads(rule: Rule) -> int:
    """Fewest reads that pay back one write: w + r*h <= 1 + r, so r >= (w - 1) / (1 - h), rounded up. Exact, from the units."""
    w, h, p = rule.units("write"), rule.units("read"), rule.units("price")
    return max(0, math.ceil(Fraction(w - p, p - h)))


# --- the match -----------------------------------------------------------------------------------

def shared_start(old, new) -> int:
    """How many tokens, counting from the first, the two prompts have in common before the first difference."""
    n = 0
    for a, b in zip(old, new):
        if a != b:
            break
        n += 1
    return n


def reuse(stored, new) -> int:
    """Tokens a new request reads from one kept start: all of it when the request begins with all of it, else none."""
    return len(stored) if shared_start(stored, new) == len(stored) else 0


@dataclass(frozen=True)
class Billed:
    read: int                   # tokens read from a stored copy
    written: int                # tokens written to a new copy
    plain: int                  # tokens billed at the normal price
    units: int                  # the bill for the input, in units
    plain_units: int            # the same prompt with no caching, in units


def bill_request(rule: Rule, total: int, reused: int, mark: int | None = None) -> Billed:
    """Bill one prompt of `total` tokens that begins with a live kept start of `reused` tokens (0 when it begins with none).

    Reading covers the whole kept start, and only if it reaches the minimum. Writing covers the rest of the part the caller
    marked (`mark` tokens; the whole prompt when None) and happens only if that part reaches the minimum.
    """
    read = reused if reused >= rule.minimum else 0
    stored = total if mark is None else min(mark, total)
    written = stored - read if stored >= rule.minimum and stored > read else 0
    plain = total - read - written
    units = read * rule.units("read") + written * rule.units("write") + plain * rule.units("price")
    return Billed(read, written, plain, units, total * rule.units("price"))


class Cache:
    """The starts a provider keeps for one rule. `request` bills one prompt arriving at `minute` and keeps what it stores."""

    def __init__(self, rule: Rule):
        self.rule = rule
        self.copies: list[list] = []         # [tokens, minute last used]

    def request(self, prompt, minute: float, mark: int | None = None) -> Billed:
        live = [c for c in self.copies if minute - c[1] <= self.rule.lifetime]
        best = max(live, key=lambda c: reuse(c[0], prompt), default=None)
        billed = bill_request(self.rule, len(prompt), reuse(best[0], prompt) if best else 0, mark)
        if billed.read:
            best[1] = minute                  # a read refreshes the copy it used
        if billed.written:
            live.append([list(prompt[:billed.read + billed.written]), minute])
        self.copies = live
        return billed


# --- a chat that re-sends its history ---------------------------------------------------------------

def chat_prompts(chat, stamp: bool = False):
    """The prompt of each turn of a lesson 7 chat: system prompt, every earlier message and reply, then the new message.
    Tokens are labels, equal where the text is equal. With stamp=True a clock line sits first and changes every turn."""
    if chat.reasoning or chat.keep is not None:
        raise ValueError("the chat must send its whole history and no hidden reasoning")
    system = [("system", i) for i in range(chat.system)]
    history: list = []
    for k in range(chat.turns):
        message = [("user", k, i) for i in range(chat.message)]
        yield ([("clock", k)] if stamp else []) + system + history + message
        history += message + [("reply", k, i) for i in range(chat.reply)]


def chat_cost(chat, rule: Rule, gap: float = 1, stamp: bool = False, cached: bool = True) -> dict:
    """The units a whole chat costs: input, with or without caching, plus output. `gap` is the minutes between turns."""
    cache, read, written, plain, units = Cache(rule), 0, 0, 0, 0
    for k, prompt in enumerate(chat_prompts(chat, stamp)):
        b = cache.request(prompt, k * gap) if cached else bill_request(rule, len(prompt), 0, 0)
        read, written, plain, units = read + b.read, written + b.written, plain + b.plain, units + b.units
    out = chat.turns * chat.reply * rule.units("output")
    return {"read": read, "written": written, "plain": plain, "input": units, "output": out, "total": units + out}


def dollars(units: int) -> Decimal:
    return Decimal(units) / Decimal(10**9)


def percent_less(base: int, cost: int) -> int:
    """Whole percent by which cost is below base (negative when above), rounded half up, in integers."""
    diff = base - cost
    return (200 * diff + base) // (2 * base) if diff >= 0 else -((200 * -diff + base) // (2 * base))


# --- time: lesson 23's best case ------------------------------------------------------------------------

def first_word_seconds(total: int, read: int) -> tuple[float, float]:
    """Best-case reading time at the chip's peak, without and with a reused start of `read` tokens (lesson 23's model).
    The newest token is always computed, so at least one token is read."""
    return prefill_sim.prefill(total).seconds, prefill_sim.prefill(max(total - read, 1)).seconds


# --- the widget's view -------------------------------------------------------------------------------------

MARKER = "[document]"
DOC_BASE = 1_000_000            # ids of the stand-in document tokens; real ids stay below 768
DOC_LENGTHS = [0, 400, 1200, 5000]
DEFAULT_DOC = 1200
AGES = [1, 4, 6, 40, 120]       # minutes since the earlier request
DEFAULT_AGE = 1
FIXED = "You are the help bot of Harbour Bakery. Answer in two short sentences.\n" + MARKER + "\nQuestion: "
EARLIER = FIXED + "Do you open on Sundays?"
CLOCK = "Time now: Tue 20 Oct, 14:05"
PRESETS = [
    {"name": "New question", "text": FIXED + "Is the rye bread vegan?"},
    {"name": "Date at the top", "text": CLOCK + "\n" + EARLIER},
    {"name": "Date at the end", "text": EARLIER + "\n" + CLOCK},
]
OPENING = PRESETS[0]["text"]


@functools.cache
def merges() -> list[tuple[int, int]]:
    """Lesson 1's 512 learned merges (trained on its own 15 KB corpus, about 0.8 s)."""
    return bpe.train(bpe.load_corpus())


def tokens(text: str, doc: int, learned=None) -> list[int]:
    """Token ids of a prompt. Each [document] marker stands for `doc` stand-in tokens, the same ones wherever the text matches."""
    learned = merges() if learned is None else learned
    out: list[int] = []
    for j, part in enumerate(text.split(MARKER)):
        if j:
            out.extend(DOC_BASE + (j - 1) * doc + i for i in range(doc))
        out.extend(bpe.encode(part, learned))
    return out


def _show(raw: bytes) -> str:
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        return " ".join(f"{b:02X}" for b in raw)
    return text.replace("\n", "↵")


def segments(ids: list[int], matched: int, learned=None) -> list[list]:
    """What the widget draws: [label, 'match' | 'rest', 1 for a document block] per token, with a run of stand-in document tokens drawn as one block."""
    table = bpe.vocab_bytes(merges() if learned is None else learned)
    out: list[list] = []                      # [label, mark, document tokens in the run (0 for an ordinary token)]
    for i, t in enumerate(ids):
        mark = "match" if i < matched else "rest"
        if t < DOC_BASE:
            out.append([_show(table[t]), mark, 0])
        elif out and out[-1][2] and out[-1][1] == mark:
            out[-1][2] += 1
        else:
            out.append(["", mark, 1])
    return [[f"document, {commas(n)} tokens", mark, 1] if n else [label, mark, 0] for label, mark, n in out]


def commas(n: int) -> str:
    return f"{n:,}"


def money(units: int) -> str:
    """Dollars to the millionth, rounded half up in integers so that the widget's JavaScript prints the same digits."""
    micro = (units + 500) // 1000
    return f"${micro // 10**6}.{micro % 10**6:06d}"


def summary(rule: Rule) -> str:
    """The rule in one line for the widget's list."""
    return f"{rule.key}: write {float(rule.write / rule.price):g}x, read {float(rule.read / rule.price):g}x, {rule.lifetime} min"


def rate(x: Decimal) -> str:
    s = f"${float(x):.3f}"
    return s[:-1] if s.endswith("0") else s


def mark_at(text: str) -> int:
    """Where the widget puts the mark: just after the line holding [document], else just before the last line."""
    i = text.find(MARKER)
    if i >= 0:
        j = text.find("\n", i)
        return len(text) if j < 0 else j + 1
    return text.rfind("\n") + 1


def mark_tokens(text: str, doc: int, learned=None) -> int:
    """How many tokens of the prompt come before the mark."""
    return len(tokens(text[:mark_at(text)], doc, learned))


def view(old: str, new: str, rule: Rule, doc: int, age: float, marked: bool, learned=None) -> dict:
    """Every line of words the widget shows for one earlier request, one new request and the settings.
    With `marked`, each request stores its start up to the mark; without it, only the whole request."""
    a, b = tokens(old, doc, learned), tokens(new, doc, learned)
    total = len(b)
    kept = mark_tokens(old, doc, learned) if marked else len(a)
    stored = kept if kept >= rule.minimum else 0
    alive = age <= rule.lifetime
    bl = bill_request(rule, total, reuse(a[:stored], b) if alive else 0, mark_tokens(new, doc, learned) if marked else None)
    d = shared_start(a[:stored], b)
    out = {"old": segments(a, stored, learned), "new": segments(b, bl.read, learned), "oldCount": len(a), "newCount": total}
    if not total:
        out.update(match="Type a new request to compare.", billed="", cost="", why="")
        return out
    if bl.read:
        out["match"] = f"{commas(bl.read)} of {commas(total)} tokens are reused from the stored start."
    elif stored and alive and d and not marked:
        out["match"] = "No token is reused: without a mark, only the whole earlier request is stored."
    else:
        out["match"] = "No token is reused."
    out["billed"] = (f"Billed: {commas(bl.read)} tokens at {rate(rule.read)} (read), {commas(bl.written)} at {rate(rule.write)} (write), "
                     f"{commas(bl.plain)} at {rate(rule.price)} (normal), per million.")
    pct = percent_less(bl.plain_units, bl.units)
    change = "about the same" if pct == 0 else f"{pct}% less" if pct > 0 else f"{-pct}% more"
    out["cost"] = f"Input cost: {money(bl.units)} with caching, {money(bl.plain_units)} without: {change}."
    if total < rule.minimum:
        out["why"] = f"The prompt is shorter than the {commas(rule.minimum)}-token minimum, so nothing is stored or read."
    elif not stored:
        out["why"] = (f"The earlier request stores {commas(kept)} tokens, under the {commas(rule.minimum)}-token minimum, "
                      "so there is no stored start to reuse.")
    elif not alive:
        out["why"] = f"The earlier request is older than {rule.lifetime} minutes, so its stored start is gone and nothing is read."
    elif bl.read:
        left = f"The other {commas(total - bl.read)} tokens are read again." if total > bl.read else "Nothing is left to read again."
        out["why"] = f"The new request begins with the whole stored start, so it is read from the cache. {left}"
    elif not d:
        out["why"] = "The very first token differs from the stored start, so none of it is reused."
    else:
        ends = "is shorter than the stored start" if d == total else f"stops matching the stored start after {commas(d)} of its {commas(total)} tokens"
        if marked:
            out["why"] = f"The new request {ends}. A stored start is reused whole or not at all."
        else:
            out["why"] = ("Nothing is reused, and the whole prompt is written as a new copy. "
                          f"Without a mark, only the whole earlier request is stored, and the new request {ends}.")
    return out
show_numbers.py48 linesDownload show_numbers.py
Show code
"""Print every number the lesson quotes. Run: PYTHONDONTWRITEBYTECODE=1 python3 show_numbers.py"""
from prefix_cache import (AGES, DEFAULT_AGE, DEFAULT_DOC, DOC_LENGTHS, EARLIER, PRESETS, RULES, Cache, bill_request, break_even_reads,
                          chat_bill as lesson7, chat_cost, dollars, first_word_seconds, mark_tokens, merges, money, percent_less, tokens, view)


def main() -> None:
    chat, (p_in, p_out) = lesson7.EXAMPLE, lesson7.EXAMPLE_PRICES
    print("tokenizer merges:", len(merges()), "| earlier request:", len(tokens(EARLIER, 0)), "text tokens,", len(tokens(EARLIER, DEFAULT_DOC)), "with the document")
    print()
    print("widget, rule A, document", DEFAULT_DOC, "tokens, earlier request", DEFAULT_AGE, "minute ago; mark at", mark_tokens(EARLIER, DEFAULT_DOC), "tokens")
    for marked in (True, False):
        for preset in PRESETS:
            v = view(EARLIER, preset["text"], RULES[0], DEFAULT_DOC, DEFAULT_AGE, marked)
            print(f"  {'marked' if marked else 'unmarked'}, {preset['name']}: {v['match']} | {v['billed']} | {v['cost']} | {v['why']} | old {v['oldCount']} new {v['newCount']}")
    print()
    print("rule | write x | read x | minimum | lifetime | reads to pay back one write, (w - p) / (p - h)")
    for r in RULES:
        w, h = r.write / r.price, r.read / r.price
        print(f"  {r.key} {r.label}: price {r.price} write {r.write} read {r.read} out {r.output} | {w}x {h}x | {r.minimum} | {r.lifetime} min | {(r.write - r.price) / (r.price - r.read):.2f} -> {break_even_reads(r)}")
    print()
    base = lesson7.bill(chat, p_in, p_out)
    print(f"lesson 7 chat {chat}: input {base.input_tokens:,} tokens, ${base.input_cost}, output ${base.output_cost}, total ${base.total}")
    for r in RULES:
        for label, kwargs in (("every minute", dict(gap=1)), ("every 6 minutes", dict(gap=6)), ("every 40 minutes", dict(gap=40)),
                              ("clock line first, every minute", dict(gap=1, stamp=True))):
            on = chat_cost(chat, r, **kwargs)
            off = chat_cost(chat, r, cached=False, **kwargs)
            print(f"  {r.key} {label}: input {dollars(on['input'])} vs {dollars(off['input'])} | total {dollars(on['total'])} vs {dollars(off['total'])} "
                  f"({percent_less(off['total'], on['total'])}% less) | read {on['read']:,} written {on['written']:,} plain {on['plain']:,}")
    on = chat_cost(chat, RULES[0])
    print(f"  rule A hit rate: {on['read']:,} / {on['read'] + on['written'] + on['plain']:,} = {on['read'] / (on['read'] + on['written'] + on['plain']):.0%}; input sums {on['read']:,} x 0.10 + {on['written']:,} x 2.50 per million")
    print()
    for total, read in ((2900, 2640), (8020, 8000), (1280, 1200), (560, 0)):
        full, rest = first_word_seconds(total, read)
        print(f"  best-case reading, {total:,} tokens, {read:,} reused: {full * 1000:.1f} ms -> {rest * 1000:.1f} ms")
    print()
    print("break-even by minimum reads in a 1,200-token start, rule A: cost of n requests")
    for n in (1, 2, 3, 4, 5):
        c = Cache(RULES[0])
        total = sum(c.request([("doc", i) for i in range(1200)], k).units for k in range(n))
        print(f"  {n} requests: {money(total)} vs {money(n * 1200 * RULES[0].units('price'))} without")
    marked = bill_request(RULES[0], 1200, 0, mark=1000)
    print(f"mark=1000 of 1,200 on rule A: read {marked.read}, written {marked.written}, plain {marked.plain}")
    print("ages:", AGES, "doc lengths:", DOC_LENGTHS)


if __name__ == "__main__":
    main()
test_prefix_cache.py197 linesDownload test_prefix_cache.py
Show code
"""Guards for prefix_cache.py: a kept start is reused whole or not at all, the bill adds up by hand, the rules behave as the lesson says."""
import os
from decimal import Decimal

import pytest

import prefix_cache as pc
from prefix_cache import RULES, Cache, bill_request, chat_bill, chat_cost, dollars, reuse, shared_start, tokens, view

A, B, C, D = RULES
EXAMPLE = chat_bill.EXAMPLE         # lesson 7: system 500, message 60, reply 200, 10 turns, $2 in and $10 out per million


def test_a_kept_start_is_reused_whole_or_not_at_all_and_what_follows_a_difference_does_not_count():
    """Guards the lesson's central claim with real text: a changed first line loses the whole start, the same line added at the end loses nothing
    before it, and a request that differs anywhere inside a kept start reads none of it (no partial reuse up to the first difference)."""
    earlier, new_question, clock_top, clock_end = (tokens(t, 0) for t in (pc.EARLIER, pc.PRESETS[0]["text"], pc.PRESETS[1]["text"], pc.PRESETS[2]["text"]))
    assert shared_start(earlier, clock_top) == 0
    assert shared_start(earlier, clock_end) == len(earlier)
    assert 0 < shared_start(earlier, new_question) < len(earlier) and earlier[-3:] != new_question[-3:]
    assert shared_start(new_question, earlier) == shared_start(earlier, new_question)
    assert reuse(earlier, clock_end) == len(earlier) and reuse(earlier, clock_top) == 0
    assert reuse(earlier, new_question) == 0                               # 41 tokens match, and none of them is reused
    assert (reuse([1, 2, 3], [1, 2, 3, 4]), reuse([1, 2, 3], [1, 2, 9]), reuse([1, 2, 3], [1, 2]), reuse([], [1])) == (3, 0, 0, 0)
    # the tokens after the first difference are equal here, and still add nothing
    one, two = [1, 2, 3, 4, 5, 6], [1, 2, 9, 4, 5, 6]
    assert shared_start(one, two) == 2
    for a, b in ((one, two), (earlier, new_question), (earlier, clock_end), ([], one), (one, one[:4])):      # an independent count, character by character
        assert shared_start(a, b) == len(os.path.commonprefix([a, b]))


def test_the_document_marker_counts_as_the_same_stand_in_tokens_wherever_it_matches():
    for doc in pc.DOC_LENGTHS:
        a, b = tokens(pc.EARLIER, doc), tokens(pc.PRESETS[0]["text"], doc)
        assert len(a) - len(tokens(pc.EARLIER, 0)) == doc
        assert shared_start(a, b) - shared_start(tokens(pc.EARLIER, 0), tokens(pc.PRESETS[0]["text"], 0)) == doc
    for doc in pc.DOC_LENGTHS:                  # the mark falls on a token boundary: every request that repeats the fixed part shares at least that many tokens
        a, mark = tokens(pc.EARLIER, doc), pc.mark_tokens(pc.EARLIER, doc)
        assert pc.shared_start(a, tokens(pc.PRESETS[0]["text"], doc)) >= mark and pc.shared_start(a, tokens(pc.PRESETS[2]["text"], doc)) >= mark
    assert pc.mark_at("x\n[document]\nlast") == 13 and pc.mark_at("a\nb\nlast") == 4 and pc.mark_at("one line") == 0 and pc.mark_at("[document] x") == 12
    ids = tokens(pc.EARLIER, 3)
    assert [s[0] for s in pc.segments(ids, 0, pc.merges()) if s[2]] == ["document, 3 tokens"]      # three stand-ins are drawn as one block
    assert [s[1] for s in pc.segments(ids, len(ids) - 1, pc.merges())].count("rest") == 1


def test_bills_by_hand():
    """1,200 tokens on rule A ($2 normal, $2.50 write, $0.10 read per million): a first request writes, a repeat reads."""
    assert bill_request(A, 1200, 0).units == 1200 * 2500 and dollars(bill_request(A, 1200, 0).units) == Decimal("0.003")
    assert bill_request(A, 1200, 1200).units == 1200 * 100 and dollars(bill_request(A, 1200, 1200).units) == Decimal("0.00012")
    assert bill_request(A, 1200, 1100).units == 1100 * 100 + 100 * 2500            # the 100 new tokens are written
    assert bill_request(A, 1200, 0).plain_units == 1200 * 2000
    # below the minimum nothing is stored or read, and the price is the plain price
    assert bill_request(A, 511, 511) == bill_request(A, 511, 0) and bill_request(A, 511, 0).units == 511 * 2000
    assert bill_request(D, 1000, 0).units == 1000 * 2000 and bill_request(D, 1024, 0).units == 1024 * 2500
    # a kept start under the minimum is not read, and the part to store is written instead
    assert bill_request(A, 1300, 200).read == 0 and bill_request(A, 1300, 200).written == 1300
    # a caller who marks only the fixed start pays the write premium on that start only
    marked = bill_request(A, 1200, 0, mark=1000)
    assert (marked.read, marked.written, marked.plain) == (0, 1000, 200)
    later = bill_request(A, 1200, 800, mark=1000)                                  # 800 read from a kept start, 200 more written up to the mark
    assert (later.read, later.written, later.plain) == (800, 200, 200)


def test_a_copy_lives_for_its_lifetime_after_its_last_use_and_a_read_renews_it():
    """Guards the five-minute rule: a request at minute 5 hits, minute 6 misses; requests every 4 minutes keep a copy alive forever."""
    prompt = [("t", i) for i in range(600)]
    for rule in (A, B, C, D):
        life = rule.lifetime
        for gap, hit in ((life, True), (life + 1, False)):
            cache = Cache(rule)
            cache.request(prompt, 0) if rule.minimum <= 600 else cache.request(prompt + prompt, 0)
            second = cache.request(prompt if rule.minimum <= 600 else prompt + prompt, gap)
            assert (second.read > 0) == hit, (rule.key, gap)
    cache = Cache(A)
    reads = [cache.request(prompt, minute).read for minute in (0, 4, 8, 12, 16)]
    assert reads == [0, 600, 600, 600, 600]
    cache = Cache(A)
    assert [cache.request(prompt, minute).read for minute in (0, 6, 12)] == [0, 0, 0]
    for rule in RULES:                                                              # the widget's view agrees: a stored start of exactly its lifetime is still read
        assert view(pc.EARLIER, pc.PRESETS[0]["text"], rule, 1200, rule.lifetime, True)["match"].startswith("1,")
        assert view(pc.EARLIER, pc.PRESETS[0]["text"], rule, 1200, rule.lifetime + 0.5, True)["match"] == "No token is reused."


def test_the_cache_reads_the_longest_kept_start_the_request_begins_with_and_nothing_from_one_it_only_partly_shares():
    cache = Cache(A)
    base, more = [("b", i) for i in range(700)], [("m", i) for i in range(300)]
    assert cache.request(base, 0).written == 700
    assert cache.request(base + more, 1).read == 700                                # two starts are kept now: 700 and 1,000 tokens
    longer = cache.request(base + more + [("x", i) for i in range(100)], 2)
    assert (longer.read, longer.written, longer.plain) == (1000, 100, 0)
    shorter = cache.request(base + [("y", i) for i in range(300)], 3)               # the 1,000-token start differs at token 700: the 700-token start still matches whole
    assert (shorter.read, shorter.written, shorter.plain) == (700, 300, 0)
    partly = cache.request(base[:650] + [("z", i) for i in range(200)], 4)           # 650 of 700 shared: all or nothing
    assert (partly.read, partly.written, partly.plain) == (0, 850, 0)
    marked = Cache(A)                                                                # a mark keeps only the start up to it, not the whole request
    assert marked.request(base + more, 0, mark=700).written == 700
    after = marked.request(base + [("y", i) for i in range(300)], 1)
    assert (after.read, after.written, after.plain) == (700, 300, 0)


def test_break_even_is_the_fewest_reads_that_repay_the_write():
    """Guards the break-even numbers (1, 2, 1, 1) against the simulation itself: n requests of one 1,200-token start, cached against not cached."""
    assert [pc.break_even_reads(r) for r in RULES] == [1, 2, 1, 1]
    for rule in RULES:
        prompt = [("doc", i) for i in range(1200)]
        def total(n):
            cache = Cache(rule)
            return sum(cache.request(prompt, k).units for k in range(n))
        extra = pc.break_even_reads(rule)
        assert total(1 + extra) <= (1 + extra) * 1200 * rule.units("price")          # one write and `extra` reads: no worse than not caching
        assert total(extra) > extra * 1200 * rule.units("price")                    # one read fewer: still behind
        assert total(1) == 1200 * rule.units("write") > 1200 * rule.units("price")   # a start used once costs the write premium


def test_example_chat_uncached_matches_lesson_7_and_cached_matches_the_closed_form():
    """Guards the chat bill: with caching off the sim must give lesson 7's $0.0546; with it on, turn 1 writes everything and every later turn
    reads the whole previous prompt and writes only the reply and message that are new."""
    off = chat_cost(EXAMPLE, A, cached=False)
    assert dollars(off["total"]) == chat_bill.bill(EXAMPLE, *chat_bill.EXAMPLE_PRICES).total == Decimal("0.0546")
    on = chat_cost(EXAMPLE, A)
    prompts = [560 + 260 * k for k in range(10)]
    assert (on["read"], on["written"], on["plain"]) == (sum(prompts[:-1]), sum(prompts) - sum(prompts[:-1]), 0) == (14400, 2900, 0)
    assert dollars(on["input"]) == Decimal("0.00869") and dollars(on["total"]) == Decimal("0.02869") and dollars(on["output"]) == Decimal("0.02")
    assert pc.percent_less(off["total"], on["total"]) == 47 and pc.percent_less(off["input"], on["input"]) == 75


def test_where_caching_saves_nothing_or_costs_more():
    """Guards the three cases the lesson names: a first line that changes every turn, turns spaced wider than the lifetime, and a start under the minimum."""
    off = chat_cost(EXAMPLE, A, cached=False)["total"]
    assert chat_cost(EXAMPLE, A, gap=1)["total"] < off
    assert chat_cost(EXAMPLE, A, gap=6)["read"] == 0 and chat_cost(EXAMPLE, A, gap=6)["total"] > off
    assert chat_cost(EXAMPLE, B, gap=6)["total"] < off                             # the 1-hour copy is still there
    stamped = chat_cost(EXAMPLE, A, stamp=True)
    assert stamped["read"] == 0 and stamped["total"] > chat_cost(EXAMPLE, A, cached=False, stamp=True)["total"]
    assert pc.percent_less(off, chat_cost(EXAMPLE, A, gap=6)["total"]) == -16
    short = chat_bill.Chat(system=100, message=20, reply=50, turns=5)               # no prompt reaches 512 tokens
    assert chat_cost(short, A)["total"] == chat_cost(short, A, cached=False)["total"]
    d = chat_cost(EXAMPLE, D)                                                      # rule D: the first two prompts are under 1,024 tokens
    assert d["plain"] == 560 + 820 and d["read"] == sum(560 + 260 * k for k in range(2, 9))


@pytest.mark.parametrize("key, every_minute, every_40_minutes, clock_first, uncached", [
    ("A", "0.02869", "0.06325", "0.063275", "0.0546"),
    ("B", "0.03304", "0.03304", "0.08924", "0.0546"),
    ("C", "0.0015065", "0.0031625", "0.00316375", "0.00273"),
    ("D", "0.031312", "0.06256", "0.062584", "0.0546"),
])
def test_chat_totals_of_the_four_rules_are_pinned(key, every_minute, every_40_minutes, clock_first, uncached):
    """Guards the output price and the lifetime of each rule: the lesson's table prints these totals, and a wrong output price (rule B 20 for 10,
    rule C 10 for 0.50, rule D 5 for 10) moved none of the other tests."""
    rule = pc.RULE_BY_KEY[key]
    assert dollars(chat_cost(EXAMPLE, rule, gap=1)["total"]) == Decimal(every_minute)
    assert dollars(chat_cost(EXAMPLE, rule, gap=40)["total"]) == Decimal(every_40_minutes)
    assert dollars(chat_cost(EXAMPLE, rule, gap=1, stamp=True)["total"]) == Decimal(clock_first)
    assert dollars(chat_cost(EXAMPLE, rule, cached=False)["total"]) == Decimal(uncached)


def test_a_reused_start_skips_its_reading_time():
    """Guards the best-case figures (lesson 23's model): reading 8,020 tokens takes 412.8 ms at the chip's peak; with 8,000 reused only 20 are read."""
    for total, read, before, after in ((8020, 8000, "412.8", "7.9"), (2900, 2640, "149.3", "13.4"), (560, 0, "28.8", "28.8")):
        full, rest = pc.first_word_seconds(total, read)
        assert (f"{full * 1000:.1f}", f"{rest * 1000:.1f}") == (before, after)
    assert pc.first_word_seconds(100, 100)[1] == pc.prefill_sim.prefill(1).seconds        # the newest token is always read


NEW_QUESTION, CLOCK_TOP, CLOCK_END = (p["text"] for p in pc.PRESETS)


@pytest.mark.parametrize("text, key, doc, age, marked, expect", [
    (NEW_QUESTION, "A", 1200, 1, True, ["1,236 of 1,254 tokens are reused from the stored start", "1,236 tokens at $0.10 (read), 0 at $2.50 (write), 18 at $2.00 (normal)",
                                        "$0.000160 with caching, $0.002508 without: 94% less", "begins with the whole stored start"]),
    (CLOCK_TOP, "A", 1200, 1, True, ["No token is reused.", "0 tokens at $0.10 (read), 1,255 at $2.50 (write), 17 at $2.00 (normal)",
                                     "$0.003172 with caching, $0.002544 without: 25% more", "very first token differs"]),
    (CLOCK_END, "A", 1200, 1, True, ["1,236 of 1,272 tokens are reused", "36 at $2.00 (normal)", "92% less", "The other 36 tokens are read again"]),
    (NEW_QUESTION, "A", 1200, 1, False, ["No token is reused: without a mark, only the whole earlier request is stored.",
                                         "0 tokens at $0.10 (read), 1,254 at $2.50 (write), 0 at $2.00 (normal)", "$0.003135 with caching, $0.002508 without: 25% more",
                                         "Nothing is reused, and the whole prompt is written as a new copy. Without a mark, only the whole earlier request is stored, and the new "
                                         "request stops matching the stored start after 1,241 of its 1,254 tokens."]),
    (pc.EARLIER, "A", 1200, 1, False, ["1,253 of 1,253 tokens are reused", "Nothing is left to read again."]),
    (pc.EARLIER.rsplit(" ", 1)[0], "A", 1200, 1, False, ["and the new request is shorter than the stored start."]),
    (pc.EARLIER.split("\n[document]")[0] + "\n[document]", "A", 1200, 1, True, ["The new request is shorter than the stored start. A stored start is reused whole"]),
    (CLOCK_END, "A", 1200, 1, False, ["1,253 of 1,272 tokens are reused", "93% less"]),
    (pc.EARLIER.replace("two short", "three short"), "A", 1200, 1, True, ["No token is reused.", "stops matching the stored start after 24 of its 1,252 tokens",
                                                                           "reused whole or not at all", "1,235 at $2.50 (write)"]),
    (NEW_QUESTION, "A", 1200, 6, True, ["25% more", "older than 5 minutes", "stored start is gone"]),
    (NEW_QUESTION, "B", 1200, 6, True, ["Billed: 1,236 tokens at $0.10 (read), 0 at $4.00 (write), 18 at $2.00 (normal)"]),
    (NEW_QUESTION, "A", 0, 1, True, ["shorter than the 512-token minimum, so nothing is stored or read", "about the same"]),
    (NEW_QUESTION, "D", 400, 1, True, ["shorter than the 1,024-token minimum"]),
    ("[document]\n[document]", "A", 400, 1, True, ["The earlier request stores 436 tokens, under the 512-token minimum"]),
])
def test_widget_words_say_what_the_settings_do(text, key, doc, age, marked, expect):
    """Guards the sentences a reader sees: each case moves one setting or the mark and checks the reused count, the tokens at each price, the cost and the reason."""
    v = view(pc.EARLIER, text, pc.RULE_BY_KEY[key], doc, age, marked)
    said = " ".join((v["match"], v["billed"], v["cost"], v["why"]))
    for part in expect:
        assert part in said, (part, said)
    assert view(pc.EARLIER, "", A, 1200, 1, True)["match"] == "Type a new request to compare."
test_widget.py309 linesDownload test_widget.py
Show code
"""Guards for ../widget.html: its data block, its size, that its tokenizer, match and sums give the same tokens, numbers and words as prefix_cache.py, and what a reader sees."""
import json
import re
import subprocess

import pytest

import prefix_cache as pc
from embed_widget import CONFIG_BLOCK, WIDGET, config, embed

HTML = WIDGET.read_text(encoding="utf-8")
ROOT = HTML[HTML.index('<div class="pcw-root">'):HTML.index("<script")]
DEFAULTS = (0, pc.DOC_LENGTHS.index(pc.DEFAULT_DOC), pc.AGES.index(pc.DEFAULT_AGE), 1)       # rule, document, age (indexes), mark ticked (1)
CUSTOM = [pc.EARLIER, pc.EARLIER[:-8], "", "café 你好 😀 [document] x", "[document]\n[document]", "\x01 tab\t and 1234567 !?", pc.PRESETS[0]["text"] + "\n\n",
          "first line\nsecond line\nlast line", pc.EARLIER.replace("two short", "three short"), pc.EARLIER.rsplit(" ", 1)[0]]
TEXTS = [p["text"] for p in pc.PRESETS] + CUSTOM
ENCODE_TEXTS = [pc.EARLIER, "", " ", "  two  spaces ", "line\nbreak\r\nand\ttab", "café 你好 😀 naïve", "1234567 and 89", "end!?.,;:", "\x00\x01\x7f", "퟿\U0010ffff", "x" * 40]

# Reads the tokenizer, the match and the sums out of the widget between its marker comments, runs them with Node, and compares every token,
# number and line of words with the ones Python wrote into the fixture.
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 cfg = JSON.parse(html.match(/<script type="application\/json" id="pcw-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf('/* ---------- the tokenizer'), html.indexOf('/* ---------- state'));
const api = new Function('cfg', "'use strict';" + js + '; return {encode, tokens, sharedStart, markTokens, view, money, percentLess, commas};')(cfg);
const bad = [];
let checks = 0;
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
fx.encodes.forEach(([text, ids]) => { checks++; if (!same(api.encode(text), ids)) bad.push(['encode', text, api.encode(text), ids]); });
fx.shared.forEach(([a, b, n]) => { checks++; if (api.sharedStart(a, b) !== n) bad.push(['sharedStart', a, b, n]); });
fx.marks.forEach(([text, doc, n]) => { checks++; if (api.markTokens(text, doc) !== n) bad.push(['markTokens', text, doc, n, api.markTokens(text, doc)]); });
fx.money.forEach(([u, text]) => { checks++; if (api.money(u) !== text) bad.push(['money', u, api.money(u), text]); });
fx.percent.forEach(([base, cost, n]) => { checks++; if (api.percentLess(base, cost) !== n) bad.push(['percentLess', base, cost, n, api.percentLess(base, cost)]); });
fx.commas.forEach(([n, text]) => { checks++; if (api.commas(n) !== text) bad.push(['commas', n, text]); });
fx.cases.forEach((c) => {
  checks++;
  const got = api.view(fx.earlier, fx.texts[c.t], cfg.rules[c.r], cfg.docs[c.d], cfg.ages[c.a], c.m === 1);
  if (!same(got, c.view)) bad.push(['view', c.t, c.r, c.d, c.a, c.m, got, c.view]);
});
console.log(JSON.stringify({ checks, bad: bad.slice(0, 3) }));
process.exit(bad.length ? 1 : 0);
"""

# Runs the page script against a small stand-in for the DOM, presses the buttons, types, moves the three settings, the mark box and the theme message,
# 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="pcw-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];

function el(tag) {
  const e = { tag, children: [], listeners: {}, value: '', checked: false, style: {}, attrs: {}, text: '' };
  Object.defineProperty(e, 'textContent', { get: () => e.text, set: (t) => { e.text = t; e.children = []; } });
  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['pcw-config'].text = config;
  page['pcw-mark'].checked = fx.markDefault;                 // the page's own HTML decides whether the box starts ticked
  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, type) => (e.listeners[type || '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 row = (box) => box.children.map((c) => [c.textContent, c.attrs['data-k'], c.attrs['data-doc'] ? 1 : 0]);
const selectNames = ['rule', 'doc', 'age'];
function look(page, t, s, what) {                  // compares the page with the view Python wrote for text t and settings s (indexes)
  const v = fx.views[[t, ...s].join('/')];
  if (!v) { bad.push([what, 'no fixture for', t, s]); return; }
  want(what + ': earlier row', row(page['pcw-old']), v.old);
  want(what + ': new row', row(page['pcw-new']), v.new);
  ['match', 'billed', 'cost', 'why'].forEach((k) => want(what + ': ' + k, page['pcw-' + k].textContent, v[k]));
}
function set(page, name, k) { page['pcw-' + name].value = String(k); fire(page['pcw-' + name], 'change'); }

const first = open();
look(first, 0, fx.defaults, 'opening state');
want('opening text', first['pcw-text'].value, fx.texts[0]);
want('nothing spoken yet', first['pcw-live'].textContent, '');
want('select values', selectNames.map((n) => first['pcw-' + n].value), fx.defaults.slice(0, 3).map(String));
want('mark box starts ticked', first['pcw-mark'].checked, true);
want('option words', selectNames.map((n) => first['pcw-' + n].children.map((o) => o.textContent)), fx.optionWords);
want('preset labels', first['pcw-btns'].children.map((b) => b.textContent), fx.presetLabels);
want('preset pressed at the opening', first['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), ['true', 'false', 'false']);

// the three presets put their text in the box, mark themselves pressed, and speak
const press = open();
[0, 1, 2].forEach((k) => {
  fire(press['pcw-btns'].children[k]);
  look(press, k, fx.defaults, 'preset ' + k);
  want('preset ' + k + ' text', press['pcw-text'].value, fx.texts[k]);
  want('preset ' + k + ' pressed', press['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), [0, 1, 2].map((j) => String(j === k)));
  const v = fx.views[[k, ...fx.defaults].join('/')];
  want('preset ' + k + ' spoken', press['pcw-live'].textContent, v.match + ' ' + v.cost);
});

// typing stays silent while it goes on and speaks when the box is left; no preset is pressed for a text of the reader's own
const typed = open();
typed['pcw-live'].textContent = '';
fx.typing.forEach((t) => {
  typed['pcw-text'].value = fx.texts[t];
  typed['pcw-live'].textContent = '';
  fire(typed['pcw-text'], 'input');
  look(typed, t, fx.defaults, 'typed text ' + t);
  want('typing stays silent at ' + t, typed['pcw-live'].textContent, '');
  want('no preset pressed at ' + t, typed['pcw-btns'].children.map((b) => b.attrs['aria-pressed']), ['false', 'false', 'false']);
  fire(typed['pcw-text'], 'change');
  const v = fx.views[[t, ...fx.defaults].join('/')];
  want('leaving the box speaks at ' + t, typed['pcw-live'].textContent, v.match + ' ' + v.cost);
});

// each of the three settings and the mark box, one option at a time, on the first preset; then all of them together
selectNames.forEach((name, n) => {
  const page = open();
  page['pcw-live'].textContent = '';
  fx.optionWords[n].forEach((w, k) => {
    const s = fx.defaults.slice(); s[n] = k;
    set(page, name, k);
    look(page, 0, s, name + ' = ' + w);
    const v = fx.views[[0, ...s].join('/')];
    want(name + ' = ' + w + ' spoken', page['pcw-live'].textContent, v.match + ' ' + v.cost);
  });
});
const unticked = open();
unticked['pcw-live'].textContent = '';
unticked['pcw-mark'].checked = false;
fire(unticked['pcw-mark'], 'change');
look(unticked, 0, [...fx.defaults.slice(0, 3), 0], 'box unticked');
const noMark = fx.views[[0, ...fx.defaults.slice(0, 3), 0].join('/')];
want('box unticked spoken', unticked['pcw-live'].textContent, noMark.match + ' ' + noMark.cost);
const mixed = open();
fx.mixed.forEach(([t, s]) => {
  mixed['pcw-text'].value = fx.texts[t];
  selectNames.forEach((name, n) => { mixed['pcw-' + name].value = String(s[n]); });
  mixed['pcw-mark'].checked = s[3] === 1;
  fire(mixed['pcw-mark'], 'change');
  look(mixed, t, s, 'mixed ' + t + ' ' + s);
});

// 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({ views: Object.keys(fx.views).length, bad: bad.slice(0, 4) }));
process.exit(bad.length ? 1 : 0);
"""


def outside_details(html):
    return re.sub(r"<details>.*?</details>", "", html, flags=re.S)


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 py_view(t, r, d, a, m):
    return pc.view(pc.EARLIER, TEXTS[t], pc.RULES[r], pc.DOC_LENGTHS[d], pc.AGES[a], bool(m))


def fixture_cases():
    """Every setting of the first three texts (the presets), and the reader's own texts at each rule, two documents, two ages and both mark states."""
    cases = {}
    for t in range(len(pc.PRESETS)):
        for r in range(len(pc.RULES)):
            for d in range(len(pc.DOC_LENGTHS)):
                for a in range(len(pc.AGES)):
                    for m in (0, 1):
                        cases[(t, r, d, a, m)] = None
    for t in range(len(pc.PRESETS), len(TEXTS)):
        for r in range(len(pc.RULES)):
            for d in (DEFAULTS[1], 1):
                for a in (DEFAULTS[2], 2):
                    for m in (0, 1):
                        cases[(t, r, d, a, m)] = None
    return {k: py_view(*k) for k in cases}


def test_widget_data_block_matches_the_python_constants():
    """Guards a constant edit that was not copied into the widget (run embed_widget.py)."""
    assert embed(HTML) == HTML


def test_embed_rewrites_a_widget_whose_data_block_drifted():
    """Guards embed() skipping the block: a hand-edited minimum or an example text must be put back."""
    drifted = HTML.replace('"minimum": 512', '"minimum": 256', 1).replace("Do you open on Sundays?", "Do you open on Mondays?", 1)
    assert drifted != HTML and embed(drifted) == HTML
    assert json.loads(CONFIG_BLOCK.search(HTML).group(2)) == json.loads(json.dumps(config()))


def test_every_element_the_script_looks_up_exists_inside_the_root():
    """Guards a widget that never starts: the parity run covers only the sums, so a misspelt id passed there and threw in the browser."""
    script = HTML[HTML.index("<script>"):]
    lookups = set(re.findall(r"\$\('([\w-]+)'\)", script)) | {f"pcw-{k}" for k in ("match", "billed", "cost", "why")}
    assert "$('pcw-' + k)" in script                      # the four text lines are looked up by suffix; they are added above
    missing = sorted(i for i in lookups if i != "pcw-config" and f'id="{i}"' not in ROOT)
    assert not missing, missing
    assert 'id="pcw-config"' in HTML


def test_widget_is_small_and_asks_for_nothing_online():
    assert len(HTML.splitlines()) <= 300 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


def test_widget_has_no_form_element():
    """Guards a dead button: the lesson page loads widget.html in a sandboxed frame, where a form cannot submit."""
    assert "<form" not in HTML and 'type="submit"' not in HTML


def test_widget_shows_at_most_five_controls_and_hides_the_rest():
    """Guards the first screen at 390 px: the box, the mark checkbox and the preset buttons (built by the script) count, and the three menus stay in a closed details element."""
    outside = outside_details(ROOT)
    assert len(re.findall(r"<(?:select|button|input|textarea)\b", outside)) + len(pc.PRESETS) <= 5
    assert "<details><summary>More settings</summary>" in ROOT and "<details open" not in ROOT
    assert len(re.findall(r"<select\b", re.search(r"<details><summary>More settings</summary>.*?</details>", ROOT, re.S).group(0))) == 3


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)", "--orange: light-dark(#b36200, #E69F00)", ":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", "#b36200", "#E69F00"}


def test_widget_tokenizer_match_and_sums_give_the_tokens_numbers_and_words_of_python(tmp_path):
    """Guards the widget's JavaScript drifting from prefix_cache.py and bpe.py: the lesson prints Python's numbers and the reader sees the
    widget's, so the two must agree on every token, on the length of the shared start, on the rounding of money and percent, and on
    every row and line of words for every setting."""
    learned = pc.merges()
    ints = [([1, 2, 3], [1, 2, 3, 4]), ([1, 2, 3], [9, 2, 3]), ([], [1]), ([5, 6, 7, 8], [5, 6, 0, 8]), ([1], [1])]
    marks = [[text, doc, pc.mark_tokens(text, doc)] for text in TEXTS for doc in pc.DOC_LENGTHS]
    prices = [(3, 1), (200, 100), (1, 0), (2, 3), (7, 8), (1000, 995), (1000, 1005), (1000, 1000), (6, 5), (5, 6)]
    cases = fixture_cases()
    fixture = {"earlier": pc.EARLIER, "texts": TEXTS, "encodes": [[t, pc.bpe.encode(t, learned)] for t in ENCODE_TEXTS],
               "shared": [[a, b, pc.shared_start(a, b)] for a, b in ints], "marks": marks, "commas": [[n, pc.commas(n)] for n in (0, 7, 999, 1000, 12345, 1234567)],
               "money": [[u, pc.money(u)] for u in (0, 499, 500, 999, 1500, 2_508_000, 999_999_500, 1_234_567_890)],
               "percent": [[a, b, pc.percent_less(a, b)] for a, b in prices],
               "cases": [{"t": k[0], "r": k[1], "d": k[2], "a": k[3], "m": k[4], "view": v} for k, v in cases.items()]}
    result = run_node(tmp_path, PARITY, fixture, lambda d: [str(d / "fixture.json"), str(WIDGET)])
    assert result["bad"] == [] and result["checks"] == len(ENCODE_TEXTS) + len(ints) + len(marks) + 6 + 8 + len(prices) + len(cases)
    reasons = ("The prompt is shorter", "The earlier request stores", "The earlier request is older", "The new request begins", "The very first token",
               "The new request stops", "Nothing is reused")
    assert all(any(v["why"].startswith(r) for v in cases.values()) for r in reasons)      # the cases reach every kind of reason


def test_widget_page_shows_what_the_lesson_names(tmp_path):
    """Guards the part a reader sees: the opening state (53 tokens in the earlier row, 94% less, and 25% more with the box unticked), the
    three presets, typing, each of the three settings and the mark box, the theme message, and the words that keep a toy from reading as a measurement (the caveat line, the key, the
    closed panels, the live region)."""
    opening = py_view(0, *DEFAULTS)
    assert opening["match"].startswith("1,236 of 1,254 tokens are reused") and opening["cost"].endswith("94% less.") and opening["oldCount"] == 1253
    unticked = py_view(0, *DEFAULTS[:3], 0)                  # automatic mode: a new question after a shared document gets no hit and pays the write price
    assert unticked["match"].startswith("No token is reused: without a mark") and unticked["cost"].endswith("25% more.")
    outside = outside_details(ROOT)
    assert '<input type="checkbox" id="pcw-mark" checked>Mark the end of the fixed part</label>' in outside
    assert "A real model splits text differently." in outside                                       # visible without opening anything
    assert re.findall(r'<li><i data-k="(\w+)"></i><span>(.*?)</span></li>', ROOT) == [("match", "Stored start, reused"), ("rest", "Not reused")]
    leaves_out = re.search(r"<details><summary>What this toy leaves out</summary>(.*?)</details>", ROOT, re.S).group(1)
    for said in ("earlier than its lifetime", "two requests may not reach the same copy", "Images and tools follow their own rules", "Real prices change"):
        assert said in leaves_out, said
    assert 'class="pcw-sr" id="pcw-live" role="status" aria-live="polite" aria-atomic="true"' in ROOT      # what a screen reader hears
    assert ".pcw-root .pcw-btns button { flex: 1 1 auto; white-space: nowrap; }" in HTML      # a button wraps to the next row, never its label
    words = [[r["summary"] for r in config()["rules"]], ["none", "400 tokens", "1,200 tokens", "5,000 tokens"],
             ["1 minute ago", "4 minutes ago", "6 minutes ago", "40 minutes ago", "2 hours ago"]]
    assert words[0][0] == "A: write 1.25x, read 0.05x, 5 min"
    cases = fixture_cases()
    mixed = [[t, [3, 1, 3, 0]] for t in (0, 3, 4)] + [[1, [1, 3, 1, 1]], [2, [2, 0, 0, 0]]]
    fixture = {"ids": sorted(set(re.findall(r'id="(pcw-[\w-]+)"', HTML))), "texts": TEXTS, "defaults": list(DEFAULTS), "optionWords": words,
               "presetLabels": [p["name"] for p in pc.PRESETS], "typing": [3, 6, 5, 4], "mixed": mixed, "markDefault": 'id="pcw-mark" checked' in ROOT,
               "views": {"/".join(map(str, k)): v for k, v in cases.items()}}
    for t, s in mixed:                                                       # the mixed settings need a view written for them too
        fixture["views"].setdefault("/".join(map(str, (t, *s))), py_view(t, *s))
    result = run_node(tmp_path, PAGE, fixture, lambda d: [str(WIDGET), str(d / "fixture.json")])
    assert result["bad"] == [] and result["views"] >= len(cases)