"""Guards for tiny_server.py: the cache against lesson 22's, the batcher's promises and counts, the server against lesson 24's, the metrics on a case worked by hand,
the story the lesson tells, and that every number the lesson, its trace and its figures print is the one the code gives."""
import importlib.util
import io
import itertools
import math
import re
import statistics
import subprocess
import sys
import xml.etree.ElementTree as ET
from contextlib import redirect_stdout
from functools import lru_cache
from pathlib import Path

import numpy as np
import pytest

import make_figs
import show
import tiny_server as ts

HERE = Path(__file__).resolve().parent
LESSON = HERE.parent
LESSONS = LESSON.parent
W = ts.WORKLOAD
N = ts.N_CHATS
SOURCE = (HERE / "tiny_server.py").read_text(encoding="utf-8")
secs, ms = show.seconds_text, show.ms_text
SHORT, LONG = 0.0078, 0.0085             # a step with no new chat takes between these many seconds, in all of the widget's runs


@lru_cache(maxsize=None)
def run(rate, pages=ts.PAGES, limit=ts.LIMIT):
    return ts.serve(W, rate, pages, limit)


@lru_cache(maxsize=None)
def metrics(rate, pages=ts.PAGES, limit=ts.LIMIT):
    return ts.report(W, rate, run(rate, pages, limit))


def load_by_path(name, relative):
    spec = importlib.util.spec_from_file_location(name, LESSONS / relative)
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    keep, sys.dont_write_bytecode = sys.dont_write_bytecode, True
    try:
        spec.loader.exec_module(module)
    finally:
        sys.dont_write_bytecode = keep
    return module


def test_pages_follow_lesson_22_block_manager():
    """Guards the cache drifting from the pool lesson 22 explained: on one seeded script of chats that grow a token at a time and end, the page tables and the
    free list (its order too) of `Pages` equal those of lesson 22's `BlockManager` after every single step."""
    paged = load_by_path("paged_kv_lesson22", "paged-kv-cache/code/paged_kv.py")
    rng = np.random.default_rng(31)
    total = 40
    manager, pages = paged.BlockManager(total, ts.PAGE_TOKENS, 1), ts.Pages(total)
    lengths = [int(x) for x in rng.integers(1, 90, size=14)]            # tokens each chat will hold when it ends
    live, promised, nxt, tokens, checked = {}, 0, 0, {}, 0
    while nxt < len(lengths) or live:
        while nxt < len(lengths) and promised + ts.pages_for(lengths[nxt]) <= total:
            manager.add_request(nxt)
            live[nxt], tokens[nxt] = lengths[nxt], 0
            promised += ts.pages_for(lengths[nxt])
            nxt += 1
        for chat in list(live):
            manager.append(chat, np.zeros(1), np.zeros(1))
            tokens[chat] += 1
            pages.grow(chat, tokens[chat])
            if tokens[chat] == live[chat]:
                manager.free(chat)
                pages.release(chat)
                promised -= ts.pages_for(live.pop(chat))
            started = {c: t for c, t in manager.tables.items() if t}       # lesson 22 opens an empty table at admission, `Pages` at the first token
            assert pages.tables == started and list(pages.free) == list(manager.free_list), (chat, tokens[chat])
            checked += 1
    assert checked == sum(lengths) and pages.used() == 0


SETTINGS = [(10, ts.PAGES, ts.LIMIT), (40, ts.PAGES, ts.LIMIT), (40, 480, 24), (40, 52, 48), (40, 60, 3), (25, 120, 8)]


def test_every_chat_is_served_once_and_no_step_over_admits():
    """Guards a request admitted with no free page, pages leaked after a chat ends, a step serving a chat twice or skipping one, and a step with more chats than the
    limit. The cache of 52 pages only just fits the longest chat, so any over-admission would run the free list dry."""
    arrive = {rate: ts.intended_times(W["gaps"], rate) for rate, _, _ in SETTINGS}
    for rate, pages, limit in SETTINGS:
        r, steps = run(rate, pages, limit), run(rate, pages, limit).steps
        where = (rate, pages, limit)
        assert all(0 <= s.held and 0 <= s.owed and s.held + s.owed <= pages and s.stepped <= limit for s in steps), where
        assert sum(s.stepped for s in steps) == sum(W["replies"]) and sum(s.joined for s in steps) == N, where
        assert steps[-1].held == 0 and steps[-1].owed == 0, where
        assert all(a < f <= d for a, f, d in zip(arrive[rate], r.first, r.done)), where


def test_toy_gives_the_times_of_lesson_24_continuous_batching():
    """Guards the toy drifting from the scheduler lesson 24 explained: with a cache that never fills and 8 places, every chat's first word and last word must equal
    that lesson's `simulate`."""
    b = ts.tail.batching
    for rate in (10, 40):
        arrive = ts.intended_times(W["gaps"], rate)
        reference = b.simulate([b.Request(t, p, r) for t, p, r in zip(arrive, W["prompts"], W["replies"])], 8, "continuous")
        mine = run(rate, 5000, 8)
        assert mine.first == reference.first_token and mine.done == reference.finished and len(mine.steps) == len(reference.steps), rate


def test_a_case_worked_by_hand():
    """Guards the metrics and the wall labels: two chats of 10 prompt tokens and 2 and 3 reply tokens, sent together, with room for one chat at a time (batch), one
    chat's pages (cache) or both. The times are sums of lesson 24's step times, the gaps between words are the three single gaps (not each chat's average), and the
    label follows the steps held out once enough chats queued."""
    work = {"gaps": [0.0, 0.0], "prompts": [10, 10], "replies": [2, 3]}
    read, step11, step12 = ts.step_seconds([], 10), ts.step_seconds([11], 0), ts.step_seconds([12], 0)
    first = [read, read + step11 + read]
    done = [read + step11, first[1] + step11 + step12]
    for pages, limit, wall, held_out in [(100, 1, "batch", (0, 2)), (1, 8, "cache", (2, 0)), (1, 1, "both", (2, 2))]:
        r = ts.serve(work, 1, pages, limit)
        m = ts.report(work, 1, r)
        assert r.first == pytest.approx(first, abs=1e-12) and r.done == pytest.approx(done, abs=1e-12), wall
        assert (m["cache_steps"], m["batch_steps"]) == held_out and ts.bottleneck({**m, "queue_max": ts.MIN_QUEUE}) == wall and ts.bottleneck(m) == "none", wall
        assert m["ttft_p99"] == pytest.approx(first[1]) and m["ttft_p50"] == pytest.approx(first[0])
        assert (m["tbt_p50"], m["tbt_p99"]) == pytest.approx((step11, step12)) and m["queue_max"] == 1 and m["pages_max"] == 1       # gaps: step11, step11, step12
    wall_of = lambda c, b, q: ts.bottleneck({"cache_steps": c, "batch_steps": b, "queue_max": q})
    assert [wall_of(c, b, ts.MIN_QUEUE) for c, b in [(10, 5), (10, 4), (4, 10), (5, 10), (1, 0)]] == ["both", "cache", "batch", "both", "cache"]
    assert [wall_of(10, 0, q) for q in (0, 1, ts.MIN_QUEUE - 1, ts.MIN_QUEUE)] == ["none", "none", "none", "cache"] and ts.MIN_QUEUE == 4     # the floor: fewer than 4 chats queued names no wall
    assert [ts.pages_for(t) for t in (1, 16, 17, 150)] == [1, 1, 2, 10]


def test_a_cache_smaller_than_the_longest_chat_is_refused():
    """Guards a server that would wait forever: a chat that can never fit must stop the run at the start, not hang it."""
    longest = max(p + r for p, r in zip(W["prompts"], W["replies"]))
    assert ts.pages_for(longest) == 52
    with pytest.raises(AssertionError, match="needs more pages"):
        ts.serve(W, 40, 51, 16)


def test_the_story_reproduces():
    """Guards the lesson's claims: no wall at the light rate, the cache at the heavy one, the batch limit once the cache doubles, nothing more from 960 pages, both walls at
    a limit of 24, a first-word wait that grows about 80 times where the gap between words grows by about half, and the same walls under every one of the widget's 6 orders.
    The sweep of all 384 widget runs guards the reason given for the gaps: a step with no new chat takes about 8 ms, a step that reads prompts takes longer on average."""
    light, heavy, more, most, high = metrics(10), metrics(40), metrics(40, 480), metrics(40, 960), metrics(40, 480, 24)
    assert [ts.bottleneck(m) for m in (light, heavy, more, most, high)] == ["none", "cache", "batch", "batch", "both"]
    assert more == most and light["queue_max"] == 0 and light["cache_full_at"] is None and more["cache_full_at"] is None
    assert 0.38 < heavy["cache_full_at"] < 0.40 and heavy["queue_max"] > more["queue_max"] > high["queue_max"]
    assert heavy["ttft_p99"] > 50 * light["ttft_p99"] and 1.4 < heavy["tbt_p99"] / light["tbt_p99"] < 1.6
    assert heavy["ttft_p99"] > more["ttft_p99"] > high["ttft_p99"] and heavy["tbt_p99"] < more["tbt_p99"] < high["tbt_p99"]
    assert heavy["served"] < ts.HEAVY and more["served"] > heavy["served"]
    walls = {setting: set() for setting in show.WALL_MOVES}
    for k in range(len(show.REROLLS)):
        for pages, limit in show.WALL_MOVES:
            walls[(pages, limit)].add(show.describe(ts.HEAVY, pages, limit, k)["lines"][2])
    assert [walls[s] for s in show.WALL_MOVES[:3]] == [{"Wall: the cache"}, {"Wall: the batch limit"}, {"Wall: the batch limit"}]
    assert walls[(480, 24)] == {"Wall: both", "Wall: the cache"}
    for order in show.REROLLS:
        shuffled = ts.tail.shuffled(W, *order)
        for rate, pages, limit in itertools.product(show.RATES, show.PAGE_STOPS, show.LIMIT_STOPS):
            steps = ts.serve(shuffled, rate, pages, limit).steps
            alone, reading = [s.seconds for s in steps if not s.joined], [s.seconds for s in steps if s.joined]
            assert SHORT < min(alone) and max(alone) < LONG < statistics.fmean(reading) / 1.3, (order, rate, pages, limit)


def reading():
    prose = (LESSON / "lesson.md").read_text(encoding="utf-8")
    return prose, {name: (LESSON / "figs" / name).read_text(encoding="utf-8") for name in make_figs.FIGURES}


def per_chat_p99(r):
    """The other kind of TBT, one average per chat, as the lesson's Go deeper quotes it (p99 over the 400 chats)."""
    return ts.percentile([(d - f) / (n - 1) for d, f, n in zip(r.done, r.first, W["replies"])], 99)


def test_every_number_the_lesson_and_its_figures_quote_comes_from_the_code():
    """Guards prose, trace and figure numbers drifting from the code: each fragment is built from a value computed here and must appear in the lesson or the figure."""
    prose, figures = reading()
    light, heavy, more, most, high = metrics(10), metrics(40), metrics(40, 480), metrics(40, 960), metrics(40, 480, 24)
    arrive = ts.intended_times(W["gaps"], ts.HEAVY)
    heavy_run = run(40)
    full = next(k for k, s in enumerate(heavy_run.steps) if "cache" in s.blocked)
    s35, s4, s1 = heavy_run.steps[full], heavy_run.steps[3], heavy_run.steps[0]
    at3 = next(s for s in reversed(heavy_run.steps) if s.start <= 3.0).queue
    lines = len(SOURCE.splitlines())
    whole = [p + r for p, r in zip(W["prompts"], W["replies"])]
    ratio = heavy["ttft_p99"] / light["ttft_p99"]
    next_chat = sum(s.joined for s in heavy_run.steps[:full])
    shuffled = [{show.describe(40, 480, 24, k)["lines"][2] for k in range(len(show.REROLLS))}]
    mib = ts.PAGES * ts.PAGE_TOKENS * ts.tail.batching.KV_BYTES_PER_TOKEN // 2 ** 20
    table = [f"| {p} | {l} | {w} | {secs(m['ttft_p99'])} | {ms(m['tbt_p99'])} |" for (p, l), w, m in
             zip(show.WALL_MOVES, ("cache", "batch", "batch", "both"), (heavy, more, most, high))]
    rerolled = [(show.describe(40, 480, 16, k)["metrics"]["ttft_p99"], show.describe(40, 240, 16, k)["metrics"]["ttft_p99"] / show.describe(10, 240, 16, k)["metrics"]["ttft_p99"])
                for k in range(len(show.REROLLS))]
    low120 = ts.load_test(10, 120)
    few = ts.load_test(10, 240, 8)
    per_gap = f"{ms(light['tbt_p99'])} and {ms(heavy['tbt_p99'])} ms"
    per_chat = f"{ms(per_chat_p99(run(10)))} and {ms(per_chat_p99(heavy_run))} ms"
    sweep = len(show.REROLLS) * len(show.RATES) * len(show.PAGE_STOPS) * len(show.LIMIT_STOPS)
    gaps = sum(W["replies"]) - N
    wanted = {"lesson.md": [
        f"one {lines}-line Python program", f"`tiny_server.py` is {lines} lines", f"1 chat in 100 (p99) waits more than {secs(light['ttft_p99'])} s for its first word",
        f"the cache fills {secs(heavy['cache_full_at'])} s in", f"that wait is {secs(heavy['ttft_p99'])} s, about 80 times longer", f"the p99 gap between two words grows only from {ms(light['tbt_p99'])} to {ms(heavy['tbt_p99'])} ms",
        f"cuts the wait to {secs(more['ttft_p99'])} s", f"{ts.HEAVY} chats a second, {ts.PAGES} cache pages, {ts.LIMIT} chats at most per step",
        f"the cache fills at {secs(heavy['cache_full_at'])} s and 1 chat in 100 waits more than {secs(heavy['ttft_p99'])} s", f"the wait is {secs(light['ttft_p99'])} s",
        f"{secs(more['ttft_p99'])} s at 480 pages becomes {secs(min(r[0] for r in rerolled))} to {secs(max(r[0] for r in rerolled))} s, and about 80 times becomes "
        f"{min(r[1] for r in rerolled):.0f} to {max(r[1] for r in rerolled):.0f}",
        f"takes {ms(ts.step_seconds([220] * 16, 0))} ms where 1 chat of 34 tokens takes {ms(ts.step_seconds([34], 0))} ms",
        f"arrive within {arrive[3] * 1000:.0f} ms", f"chat 0 needs {ts.pages_for(whole[0])} (44 tokens at 16 a page), chat 2 needs {ts.pages_for(whole[2])}",
        f"Trace step 1 takes {ms(s1.seconds)} ms with one chat. Trace step 4 takes {ms(s4.seconds)} ms", f"{W['prompts'][2]} and {W['prompts'][3]} tokens",
        f"At trace step {full + 1}, {s35.held} pages are held and {s35.owed} more are owed", f"Only {ts.PAGES - s35.held - s35.owed} of {ts.PAGES} are left and the next chat in line needs "
        f"{ts.pages_for(whole[next_chat])}", f"with {s35.stepped} of the {ts.LIMIT} places used", f"reach the ceiling at {secs(heavy['cache_full_at'])} s",
        f"the queue reaches {at3} chats by 3 s", "about 80 times as long", f"the p99 gap between words grows by about half, from {ms(light['tbt_p99'])} to {ms(heavy['tbt_p99'])} ms",
        "A step with no new chat takes about 8 ms, so the slow gaps are the steps that read new prompts", "Every running chat waits through it",
        f"They peak at {more['pages_max']} pages in use", f"the p99 wait is {secs(more['ttft_p99'])} s", f"960 pages gives the same {secs(most['ttft_p99'])} s",
        f"cuts the wait to {secs(high['ttft_p99'])} s", f"at a small price: the p99 gap between words rises from {ms(heavy['tbt_p99'])} to {ms(more['tbt_p99'])} to {ms(high['tbt_p99'])} ms",
        f"{heavy['pages_max']} of {ts.PAGES} pages held", f"{more['pages_max']} of 480 held", f"Even the median chat waited {secs(heavy['ttft_p50'])} s",
        f"the p99 gap between words by about half. Averaging each chat's gaps would show {ms(per_chat_p99(run(10)))} to {ms(per_chat_p99(heavy_run))} ms and hide the slow steps",
        f"a limit of 24 cut the wait to {secs(high['ttft_p99'])} s while the p99 gap between words rose to {ms(high['tbt_p99'])} ms",
        f"prompts of {min(W['prompts'])} to {max(W['prompts'])} tokens, replies of {min(W['replies'])} to {max(W['replies'])}, and {sum(whole) / N:.0f} tokens on average",
        f"their {sum(whole):,} tokens", f"The toy's {ts.PAGES} pages are {mib} MiB", f"the {math.ceil(99 * N / 100)}th shortest and {N - math.ceil(99 * N / 100)} chats are slower",
        f"TBT pools every gap between two words of every chat, {gaps:,} of them", f"chat 0 is promised {ts.pages_for(whole[0])} pages when it is admitted, chat 2 is promised {ts.pages_for(whole[2])}",
        f"at least {ts.MIN_QUEUE} chats (`MIN_QUEUE`, 1 in 100 of the {N}) ever waited at once", "where one chat ever waited, would blame the batch limit",
        f"{low120['queue_max']} chats queue and the p99 wait is {secs(low120['ttft_p99'])} s", "At 120 pages the same rate still names the cache",
        f"held a chat out in {heavy['cache_steps']:,} steps and the batch limit in {heavy['batch_steps']}", f"the limit {more['batch_steps']} steps",
        f"the counts are {high['cache_steps']} and {high['batch_steps']}: both", f"{len(shuffled[0])}", "reads \"both\" in 4 of 6 orders and \"the cache\" in the others",
        f"finishes {heavy['served']:.1f} chats a second on average, fewer than the {ts.HEAVY} sent", f"| {ts.PAGES} | {ts.LIMIT} | cache | {secs(heavy['ttft_p99'])} | {ms(heavy['tbt_p99'])} |",
        "| Pages | Limit | Wall | p99 first word (s) | p99 gap (ms) |",
        f"at 10 and 40 chats a second the p99 is {per_gap}", f"Its p99 is {per_chat}", "records both: a histogram with one sample per streamed output", "chunked prefill, described on the vLLM Optimization page below",
        f"a sweep of the widget's {sweep} runs checks that a step with no new chat takes {SHORT * 1000:.1f} to {LONG * 1000:.1f} ms",
        "`vllm:inter_token_latency_seconds`, which records one sample per streamed output; the per-chat average above lines up with `vllm:request_time_per_output_token_seconds`", "large enough to use the GPU well and small enough to avoid much waste in most workloads",
        "Lesson 22 and [lesson 28](../what-model-server-settings-change/) call a page a block", "The toy knows each reply's length, so it can promise all of its pages; a real server cannot, and fills later (Go deeper)",
        "The dining room sends the orders", "The cook takes an order off the rail only if two things hold: the shelf has room for it, and a plate is free",
        "configuration/optimization/", "read 2026-10-20)", "Figure 3. At 240 pages and 16 chats, the wait for the first word grows about 80 times; the gap between words grows by about half",
        *table, f"{ts.pages_for(150)} pages", f"{most['pages_max']} of the 960 pages", "At 480 pages and a limit of 24"] +
        ["Peak use is %d pages of 960" % most["pages_max"], f"to {secs(high['ttft_p99'])} s, and raises the p99 gap between words from {ms(more['tbt_p99'])} to {ms(high['tbt_p99'])} ms",
         "the p99 gap between words only by about half", "Only steps that read new prompts are slow, and every running chat waits through them"],
        "kitchen.svg": [f"load generator: {ts.HEAVY} chats a second", "the dining room sends the orders", "the order rail", f"{ts.LIMIT} chats at most", f"{ts.PAGES} pages", "p50, p99, queue"],
        "cache-fills.svg": [f"cache full at {secs(heavy['cache_full_at'])} s", f"{at3} waiting at 3 s", f"{ts.PAGES} pages: the cache is full", "cache pages promised"],
        "two-waits.svg": [f"{secs(light['ttft_p99'])} s", f"{secs(heavy['ttft_p99'])} s", f"{ms(light['tbt_p99'])} ms", f"{ms(heavy['tbt_p99'])} ms", "about 80 times", "grows by about half"],
        "wall-moves.svg": [f"{p} pages, up to {l} chats" for p, l in show.WALL_MOVES] + [f"{secs(m['ttft_p99'])} s" for m in (heavy, more, high)] +
                          [f"wall: {w}" for w in ("the cache", "the batch limit", "both")],
    }
    assert 75 <= ratio <= 85 and 1.4 < heavy["tbt_p99"] / light["tbt_p99"] < 1.6 and lines < 150 and "25,720" in (LESSONS / "what-model-server-settings-change" / "lesson.md").read_text(encoding="utf-8")
    assert ts.MIN_QUEUE == N // 100 and few["queue_max"] == 1 and ts.bottleneck(few) == "none" and ts.bottleneck(low120) == "cache" and 1.0 < low120["ttft_p99"] < 1.3
    assert (round(min(r[0] for r in rerolled), 2), round(max(r[0] for r in rerolled), 2)) == (0.39, 1.22) and sweep == 384
    texts = {"lesson.md": prose, **figures}
    missing = [f"{name}: {fragment!r}" for name, fragments in wanted.items() for fragment in fragments if fragment not in texts[name]]
    assert not missing, "not found: " + "; ".join(missing)


def test_the_figures_are_the_ones_the_code_draws_and_follow_the_house_style():
    """Guards a figure going stale or off-palette: the files on disk equal what make_figs draws, the lesson shows each one, and each parses, has a title and a description,
    a white ground, no script, no web address, only the house colours and no text under 20 units."""
    prose, figures = reading()
    assert set(figures) == set(re.findall(r"\]\(figs/([\w-]+\.svg)\)", prose)) == set(make_figs.FIGURES)
    allowed = {make_figs.INK, make_figs.GREY, make_figs.ORANGE, make_figs.GREEN, make_figs.FILL, make_figs.PANEL, "#ffffff"}
    web_address = re.compile("ht" + r"tps?://")
    for name, svg in figures.items():
        assert svg == make_figs.FIGURES[name](), name
        root = ET.fromstring(svg)
        assert root.find("{%s}title" % make_figs.NAMESPACE) is not None and root.find("{%s}desc" % make_figs.NAMESPACE) is not None and 'fill="#ffffff"' in svg, name
        assert "<script" not in svg and not web_address.search(svg.replace(make_figs.NAMESPACE, "")) and root.get("width") == "600", name
        assert {c.lower() for c in re.findall(r"#[0-9a-fA-F]{6}\b", svg)} <= {c.lower() for c in allowed}, name
        assert all(int(size) >= 20 for size in re.findall(r'font-size="(\d+)"', svg)), name
    for path in HERE.glob("*.py"):
        assert not web_address.search(path.read_text(encoding="utf-8")), path.name


def table_rows(prose):
    return re.findall(r"^\| \d\. [^|]+\| `(\w+\.py):(\d+)`<br>`(.+?)` \|", prose, re.M)


def test_code_blocks_are_slices_of_the_real_file_and_the_table_points_at_its_lines():
    """Guards a `file:line` in the code table pointing at the wrong line, and a code block in Go deeper that no longer matches the file."""
    prose, _ = reading()
    blocks = re.findall(r"```python\n(.*?)\n```", prose, re.S)
    assert len(blocks) == 5 and all(block in SOURCE for block in blocks)
    rows = table_rows(prose)
    assert [int(n) for _, n, _ in rows] == [85, 90, 96, 57, 120, 131]
    for name, number, code in rows:
        line = (HERE / name).read_text(encoding="utf-8").splitlines()[int(number) - 1]
        assert re.sub(r"\s+# .*$", "", line).strip() == code, (name, number)
    assert "self.free.extend(self.tables.pop(chat))" in SOURCE.splitlines()[59] and "Line 60 gives them all back" in prose


def run_trace(source):
    out = io.StringIO()
    with redirect_stdout(out):
        exec(compile(source, "trace.py", "exec"), {"__name__": "__main__"})
    return out.getvalue()


def test_trace_is_the_block_in_the_lesson_and_the_your_turn_answer_is_right():
    """Guards the pasted run drifting from `python3 trace.py` (the real command, from the code folder) and the Your turn answer: changing line 7 to 960 pages must change
    only the label of the last row, and the details block must be that block."""
    prose, _ = reading()
    real = subprocess.run([sys.executable, "-B", "trace.py"], cwd=HERE, capture_output=True, text=True, check=True).stdout
    pasted = re.search(r"python3 trace\.py\n```\n\nPhone readers: slide the box sideways to see every column\.\n\n```text\n(.*?)\n```", prose, re.S).group(1)
    assert real == pasted + "\n"
    trace = (HERE / "trace.py").read_text(encoding="utf-8")
    assert trace.splitlines()[6] == "MORE_PAGES = 480  # the cache of the second heavy run" and "change line 7 to `MORE_PAGES = 960`" in prose
    changed = run_trace(trace.replace("MORE_PAGES = 480", "MORE_PAGES = 960", 1))
    start = real.index("the clipboard")
    assert changed[:start] == real[:start] and changed[start:] == real[start:].replace("heavy, 480 pages", "heavy, 960 pages")
    answer = re.search(r"<summary>What you should see</summary>.*?```text\n(.*?)\n```", prose, re.S).group(1)
    assert answer + "\n" == changed[start:] and "343" in answer
