On this page
Take a shop’s help bot with a cache, a shelf of ready answers kept to save work. In a toy run of 14 requests from three made-up customers, the shelf answered 10 of them without asking the bot, and 6 of those answers went to the wrong customer: order codes and streets crossed between people. The bot’s model mixed nothing up. The shelf did.
In one minute
Yes, a user’s chat can leak into another’s, but usually not through the model: it leaks through the software around it, such as a cache that forgets who asked. Picture a pharmacy that keeps ready bags on a shelf labelled only with the medicine’s name. The next person who asks for that medicine gets someone else’s bag. A cache does the same when its key, the label an answer is filed under, leaves out the user. The plain fix is to put the user in every key and keep private text out of shared places.
Explore the numbers
Open the widget. It opens on the shared shelf, with every wrong answer marked LEAK. Tap a request to see who asked, the key looked up and whose answer came back. Tick Put the user in the key and watch the leaks go and the hit rate, the share of requests the shelf answers, fall. Under More settings, see how a short wait can give away what the server kept.
How it works
What does not leak
While a model writes an answer, its weights, the numbers it learned in training, do not change. It also keeps nothing between replies: the app sends the chat again each time, as Why does a chatbot forget? shows. So the model does not carry one user’s chat into another’s. Whether a provider later trains on chats is a separate question of settings and policy, and this lesson leaves it aside.
A shelf that forgets who asked
A response cache keeps whole answers, so a repeated question skips the bot. It finds an answer by its cache key. If the key is the question alone, the second person to ask “Where is my order?” gets the first person’s answer. That is a hit, a request the shelf already holds, and here a leak. User A asks first, the shelf keeps Order A-1042, and User B is handed it.
Figure 1. The key has no user in it, so User B’s request finds User A’s answer.
Over the 14 requests, this key gave 10 hits, 6 of them leaks, a hit rate (the share of requests the shelf answered) of 71%. The answer to opening hours is the same for everyone, so that hit does no harm. The leaks are the answers that carry a private fact.
Putting the user in the key
Now file each answer under the user plus the question. User B’s key, “B | Where is my order?”, differs from User A’s, so B misses and the bot writes B’s own answer. The same 14 requests give 5 hits, no leaks and a hit rate of 36%. The key gives up one harmless hit, opening hours, because the toy cannot tell a public answer from a private one.
Figure 2. Each user reads only their own entry.
A wait can give a cache away
A prompt cache is different: it returns the right answer to everyone. A hit means the server skips reading a stored start, the opening of an earlier prompt that it kept, so the first word comes sooner (What is prompt caching?, time to first token). If users share the stored start, User B can learn from a shorter wait that someone recently sent it. In the toy a hit waits 7.9 milliseconds (ms) at best and a miss 52.5 ms. Random delay hides a single try: the quickest of three guesses is the real one 68% of the time, and 99% after averaging 25 waits each. Chance is 33%. Mix the user into the key, as a value called a cache salt, and every guess waits the same: 33% either way. Real timing is noisier, and this is a simulation.
Two more routes
Logs, the records a system writes of what it did, can leak: if they keep every full prompt and answer, anyone who can read them reads every user’s chat. A mix-up between requests can leak too. In March 2023 OpenAI reported that a bug in an open-source library, used with its cache of user information, could return data from a connection left out of step by a cancelled request, so that data meant for one user reached another. Some users saw titles from another active user’s chat history, and payment details of 1.2% of ChatGPT Plus subscribers active in a nine-hour window may have been exposed. OpenAI added checks that cached data matches the requesting user.
Figure 3. Dashed orange: routes that leak. Solid grey: the model.
What this toy simplifies
Real caches store pieces of prompts, expire entries and are separated by the provider in ways a customer cannot see. The shop, the users and every number here are made up.
What this means when you build
- Put the user in every key. Use the account or user in each response cache key, and in the prompt cache’s key or salt where your provider or server lets you set one.
- Never cache personal answers across users. Share a key only for answers that are the same for everyone, such as opening hours.
- Log less and keep logs private. Hide private details, limit who reads the logs and delete them soon.
- Test with two accounts. Have both ask the same question, and check that neither gets the other’s answer.
- Ask your provider how its caches are separated. By organization, by workspace, or by user?
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 toy shop
Three made-up users each have an order code and a street: A (A-1042, Mill Lane), B (B-2210, Quarry Road) and C (C-3377, Orchard Close). The bot answers four questions: where is my order, what is my delivery address, what are your opening hours, and can you gift-wrap order N. Three answers carry a private fact. Opening hours are the same for everyone. The fourth question holds the asker’s own order code, so two users never type the same words, and a repeat of it is a safe hit in both modes.
The 14 requests come in a fixed order. In the tables, Shared is the key of the question alone and Keyed is the user key, the user plus the question. In the first table, “order”, “street” (the delivery address), “hours” and “gift” (gift-wrap) name the four questions. “hit 3” means the cache returned the answer written at request 3, and “LEAK 1” means it returned the answer written at request 1 for a different user.
| Request | Shared | Keyed |
|---|---|---|
| 1. A: order | miss | miss |
| 2. B: order | LEAK 1 | miss |
| 3. A: hours | miss | miss |
| 4. C: hours | hit 3 | miss |
| 5. B: street | miss | miss |
| 6. A: street | LEAK 5 | miss |
| 7. C: order | LEAK 1 | miss |
| 8. A: order | hit 1 | hit 1 |
| 9. B: gift | miss | miss |
| 10. B: gift | hit 9 | hit 9 |
| 11. C: street | LEAK 5 | miss |
| 12. B: order | LEAK 1 | hit 2 |
| 13. A: hours | hit 3 | hit 3 |
| 14. C: order | LEAK 1 | hit 7 |
The two keys in code
The two modes differ in one line. A miss stores the right answer for the asker; a hit returns whatever is stored, and a leak is a hit whose answer is not the right one for this user. The cache never expires.
def cache_key(user: User, text: str, per_user: bool) -> str:
return f"{user.name} | {text}" if per_user else text
def simulate(per_user: bool, sequence=SEQUENCE) -> list[Step]:
store: dict[str, tuple[str, int]] = {}
steps = []
for n, (name, q) in enumerate(sequence, 1):
user, text, right = USER_BY_NAME[name], question(USER_BY_NAME[name], q), answer(USER_BY_NAME[name], q)
key = cache_key(user, text, per_user)
if key in store:
served, by = store[key]
steps.append(Step(n, name, text, key, True, by, served, right, served != right))
else:
store[key] = (right, n)
steps.append(Step(n, name, text, key, False, None, right, right, False))
return steps
These are the real functions, with their comments left out. leak_sim.py has the full file.
The counts
With the shared key the 14 requests give 10 hits, 6 leaks and a hit rate of 71%. With the user in the key they give 5 hits, 0 leaks and 36%. The first rate is flattering, because 6 of its 10 hits were wrong. Count only hits that returned the right answer, and in this sequence the user key does not lose: 5 against 4. It gives up request 4, the harmless hours hit, and wins requests 12 and 14, which were leaks under the shared key. A shared key is fine for an answer that is the same for everyone, but the toy does not separate those, so its user mode gives that hit up.
The timing toy
Each prompt has 900 tokens that start every request of the shop’s bot, 100 tokens of the user’s note and 20 tokens of the new question: 1,020 in all. A token is a small piece of text. Suppose User A’s note names Mill Lane and the server stored the first 1,000 tokens. User B sends the same instruction with a note naming a guess: Elm Road, Mill Lane or Fern Close.
Without the user in the key, B’s right guess begins with the whole stored start, so only its last 20 tokens are read. A wrong guess matches only the 900-token instruction, which is less than the whole stored start, and a stored start is reused whole or not at all, so all 1,020 tokens are read. Best-case reading times come from the prefill_sim code of lesson 23: 7.9 ms for 20 tokens and 52.5 ms for 1,020. With the user in the key, a start is stored and found only under the same user, so B’s guesses all read 1,020.
Every wait also gets a random queueing delay, drawn from an exponential spread with a mean of 60 ms, a toy figure. One experiment draws 25 waits for each guess. The first table gives the average wait in ms over 2,000 experiments, with the fixed seed 2026 and the same draws in both modes, so only the key differs (Shared: the question alone; Keyed: the user plus the question):
| Guess | Shared | Keyed |
|---|---|---|
| Elm Road | 112 | 112 |
| Mill Lane | 68 | 112 |
| Fern Close | 112 | 112 |
Then User B picks the guess with the shortest wait, from one try each or from the average of 25. In how many experiments is that the real street?
| Waits used | Shared | Keyed |
|---|---|---|
| One try | 68% | 33% |
| 25 tries | 99% | 33% |
Figure 4. Only the real street waits less, and only with a shared key. Green is a stored start reused; dashed orange is a start read again.
The gap between a hit and a miss is 44.6 ms, and the delay has a mean and a spread of about 60 ms, so one try often hides it. An average of n draws varies about 1/sqrt(n) as much, so 25 tries cut the spread to about 12 ms, well under the gap. With the user in the key there is no gap, and no number of tries finds one: 33% is a blind guess. The toy resets the cache before every try, so that each try sees the same start. A real cache changes with every request. It would store each try too, which changes what the next try sees.
What the toy leaves out
Real prompt caches store a prompt in blocks, expire entries after minutes and are kept apart by the provider, by organization and sometimes by workspace, in ways a customer cannot see. With blocks, a wrong guess would still reuse the shared 900-token instruction and read only 120 tokens. The toy’s model puts those 120 tokens at 7.9 ms, like the right guess’s 20, because a short pass waits on memory, not on arithmetic. A toy with blocks would then show a gap of 0.0 ms, so the toy’s 44.6 ms gap comes from its whole-or-nothing rule. That does not make a shared cache safe: a real gap grows with the length of the part that differs, so a small one takes many samples to see. Real timing includes the network and the queue, which is far noisier than this delay. The toy only shows the mechanism, a timing side channel, which means learning a hidden fact from how long a system takes: a shared start can be seen from the wait, and the user in the key removes it. Gu and colleagues audited real APIs with statistical tests over many time-to-first-token samples, and reported finding cache sharing across users in some. They argue that to completely prevent privacy leakage from prompt caching, only per-user caching should be allowed, and that providers should say what their policy is. The authors told each provider where they detected prompt caching before publishing, and to their knowledge at least five made changes to mitigate vulnerabilities.
A real incident
OpenAI’s write-up of the 20 March 2023 outage gives the cause as a bug in the open-source redis-py library. OpenAI used Redis to cache user information over a shared pool of connections. A request cancelled after it was pushed to the queue but before its response was popped left a connection out of step, and the next response on it could be data left behind for someone else. Usually that gave an error, but sometimes the data matched the expected type, “so what gets returned from the cache appears valid, even if it belongs to another user.”
OpenAI said a change on its server at 1 a.m. Pacific on March 20 caused a spike in cancellations. Some users could see titles from another active user’s chat history, and the first message of a new conversation may have been visible to another user. The same bug may have shown first and last name, email address, payment address, card type, last four digits and expiry date for 1.2% of ChatGPT Plus subscribers active during a nine-hour window; full card numbers were not exposed. OpenAI said it believed the number of users whose data was actually revealed to someone else was “extremely low”. OpenAI took the service offline, patched the bug and added redundant checks that the data returned from its cache matches the requesting user.
What providers and servers say
These pages were read on 2026-10-19 and change, so check the live page. These are documentation claims, not audit results. OpenAI’s prompt caching guide says “Caches are not shared across organizations” and, for GPT-5.6 and later, describes prompt_cache_key as a way to keep separate cache accounting for customers, users or workspaces, adding that it “also helps prevent cache-hit probing across customers”. Anthropic’s documentation says “Caches are isolated between organizations” and, on some platforms, per workspace. The Anthropic page describes no per-user setting. The vLLM server documents an optional per-request cache_salt, mixed into the short code made from the first block, so that only requests with the same salt reuse cached blocks. Its page says this prevents timing-based attacks.
Proof it works
test_leak_sim.py pins the counts above: 14, 10, 6 and 71% with the shared key, and 14, 5, 0 and 36% with the user in it. It runs 300 random requests to check that every leak carries another user’s order code or street and that opening hours never leaks. It runs 40 random sequences to check that the user in the key leaves no leak and never gives more hits. It checks that a stored start is reused whole or not at all, and only under the same salt. It checks that only the right guess is quick without the user in the key, and that all three wait the same with it. It pins the noise: 68% and 99% shared, 33% and 33% salted, the same on a second run. It also rebuilds the numbers in the text, the tables and the figures from the code’s results, so a number that drifts fails the test. test_widget.py runs the widget’s JavaScript in Node against the Python in both modes, and drives the page through a small stand-in for the browser, checking the text, the bar widths and the line a screen reader announces.
Skills this lesson builds
- Explain why a cache keyed by the question alone can hand one user’s answer to another.
- Say what does not leak (the model’s weights) and the three places a leak does come from.
- Put the user in a cache key, and count what it costs in hits.
- Read a timing signal from noisy waits, and say why many tries and a salt matter.
- Test an app with two accounts that ask the same question.
Portfolio project: build a small help bot with a response cache whose key can be the question alone or the user plus the question. Write a test with three users and a fixed list of requests that counts hits and wrong answers in both modes. Publish the code, the table of counts and a note on which answers you let share a key and why.
Check yourself
-
A help bot caches whole answers under the question text alone. User B asks “Where is my order?” right after User A. What goes wrong, and what is the fix?
Answer
The cache finds A’s stored answer under the same key and hands it to B, so B sees A’s order. The fix is to put the user in the key, so that each user finds only their own entries.
-
Does the model itself carry one user’s chat into another’s?
Answer
Not usually. Its weights do not change while it answers, and it keeps nothing between replies. Leaks come from the software around it: a shared cache key, logs that keep full text, or mixed-up requests.
-
In the toy, the hit rate falls from 71% to 36% when the user goes in the key. Why is that not a loss of right answers?
Answer
Six of the shared key’s 10 hits were leaks, so only 4 were right. The user key has 5 hits and all are right: it gives up one harmless hit (request 4) and wins two (requests 12 and 14).
-
With the shared key, one try per guess finds the real street 68% of the time and the average of 25 finds it 99%. With the user in the key both give 33%. Why?
Answer
The real guess waits 44.6 ms less, but the random delay of about 60 ms hides that in one try. Averaging 25 waits cuts the noise to about 12 ms, so the gap shows. With the user in the key there is no gap to find, so more tries change nothing and the result is a blind guess, one in three.
-
OpenAI said it added “redundant checks” that data from its cache matches the requesting user. What would such a check catch?
Answer
A response that is valid in form but belongs to another user, as happened when a cancelled request left a connection out of step. The check compares the owner of the data with the user who asked, and refuses to show data for the wrong user.
Further reading
Web pages were read on 2026-10-19.
Gentle
- Why does a chatbot forget? (lesson 3, why the model keeps nothing between replies)
- What is prompt caching, and when does it save money? (lesson 8, the stored start whose reuse is shortened by a hit)
- Why does an AI wait a moment before the first word? (lesson 23, the wait that a hit shortens)
- March 20 ChatGPT outage: Here’s what happened (OpenAI, 24 March 2023; the incident, its cause and what was visible)
Deep
- Auditing Prompt Caching in Language Model APIs (Gu, Li, Kuditipudi, Liang and Hashimoto, 2025; timing audits and the case for per-user caching)
- Prompt caching (OpenAI; caches not shared across organizations, and
prompt_cache_key) - Prompt caching (Anthropic; “Cache storage and sharing”, isolation between organizations and workspaces)
- Automatic Prefix Caching (vLLM design page; “Cache Isolation for Security” and the per-request
cache_salt)
Optional
Code
Each file runs on its own. Download it, or read it here.
Show code
"""Keep the data inside ../widget.html equal to the data in leak_sim.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
from leak_sim import EXPERIMENTS, GUESSES, QUESTIONS, RIGHT, SEQUENCE, TRIES, USERS, percent, simulate, timing
WIDGET = Path(__file__).resolve().parent.parent / "widget.html"
CONFIG_BLOCK = re.compile(r'(<script type="application/json" id="lk-config">)(.*?)(</script>)', re.S)
def config():
shared, salted = timing(False), timing(True)
average = {"shared": [round(w) for w in shared["average"]], "salted": [round(w) for w in salted["average"]]}
return {"users": [{"name": u.name, "order": u.order, "street": u.street} for u in USERS],
"questions": [{"q": q, "a": a} for q, a in QUESTIONS],
"sequence": [list(r) for r in SEQUENCE],
"opening": next(s.n for s in simulate(False) if s.leak),
"guesses": GUESSES, "right": RIGHT, "tries": TRIES, "runs": EXPERIMENTS, "chance": percent(1, len(GUESSES)),
"axis": -(-max(max(v) for v in average.values()) // 10) * 10,
"timing": {"shared": {"average": average["shared"], "one": shared["one"], "many": shared["many"]},
"salted": {"average": average["salted"], "one": salted["one"], "many": salted["many"]}}}
def embed(html):
"""html with the config block rewritten from leak_sim.py."""
block = json.dumps(config())
return CONFIG_BLOCK.sub(lambda m: m.group(1) + "\n" + block + "\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 leak_sim.py")Show code
"""Can one user's chat leak into another's? A small simulation of two shared caches in front of a shop's help bot.
Python 3 standard library only (plus lesson 23's code, imported, not copied). Run `python3 show_numbers.py` to print every
number the lesson quotes.
The toy, in five rules:
1. A made-up shop has three made-up users. Each has two private facts, an order code and a street.
2. The help bot answers four kinds of question. Three answers carry a private fact (an order code or a street) and
one, opening hours, is the same for everyone.
3. A response cache sits in front of the bot and keeps whole answers. Its key is either the question alone ("shared")
or the user plus the question ("per user"). A request whose key is already stored is a hit and gets the stored answer.
4. A hit is a leak when the stored answer is not the answer this user should get. In this toy every such answer carries
another user's order code or street.
5. A prompt cache (lesson 8's idea: a stored start of a prompt is reused whole or not at all) makes the wait before the
first word shorter when it is hit. The waits come from lesson 23's best-case reading time plus a random queueing delay
with a fixed seed. Real waits are noisier and real caches store per block; nothing here touches a real service.
"""
from __future__ import annotations
import random
import sys
from dataclasses import dataclass
from pathlib import Path
LESSONS = Path(__file__).resolve().parents[2]
sys.path.append(str(LESSONS / "why-ai-waits-before-the-first-word" / "code"))
import prefill_sim # lesson 23: the best-case time of reading a prompt
# --- the shop, its users and its questions -------------------------------------------------------------------
@dataclass(frozen=True)
class User:
name: str # "A", "B", "C": the widget and the lesson write "User A"
order: str # a made-up order code
street: str # a made-up street, no number, no town
USERS = [User("A", "A-1042", "Mill Lane"), User("B", "B-2210", "Quarry Road"), User("C", "C-3377", "Orchard Close")]
USER_BY_NAME = {u.name: u for u in USERS}
# (question as the user types it, the answer). {order} and {street} are filled from the user.
QUESTIONS = [
("Where is my order?", "Order {order} is out for delivery."),
("What is my delivery address?", "We deliver to {street}."),
("What are your opening hours?", "We open 9 to 5, Monday to Saturday."),
("Can you gift-wrap order {order}?", "Yes, order {order} can be gift-wrapped."),
]
PUBLIC = 2 # the question whose answer is the same for everyone
# (user, question number): fixed, so every run gives the same table.
SEQUENCE = [("A", 0), ("B", 0), ("A", 2), ("C", 2), ("B", 1), ("A", 1), ("C", 0),
("A", 0), ("B", 3), ("B", 3), ("C", 1), ("B", 0), ("A", 2), ("C", 0)]
def fill(template: str, user: User) -> str:
return template.replace("{order}", user.order).replace("{street}", user.street)
def question(user: User, q: int) -> str:
"""The words the user types; question 3 holds the user's own order code, so it differs from user to user."""
return fill(QUESTIONS[q][0], user)
def answer(user: User, q: int) -> str:
"""The answer this user should get."""
return fill(QUESTIONS[q][1], user)
# --- the response cache --------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Step:
n: int # request number, from 1
user: str
question: str
key: str # what the cache looks up
hit: bool
written_by: int | None # on a hit, the number of the request whose answer is stored
served: str # the answer the user got
right: str # the answer the user should have got
leak: bool # served is not right
def cache_key(user: User, text: str, per_user: bool) -> str:
return f"{user.name} | {text}" if per_user else text
def simulate(per_user: bool, sequence=SEQUENCE) -> list[Step]:
"""Run the requests through a response cache that never expires. A miss writes the right answer for the asker."""
store: dict[str, tuple[str, int]] = {}
steps = []
for n, (name, q) in enumerate(sequence, 1):
user, text, right = USER_BY_NAME[name], question(USER_BY_NAME[name], q), answer(USER_BY_NAME[name], q)
key = cache_key(user, text, per_user)
if key in store:
served, by = store[key]
steps.append(Step(n, name, text, key, True, by, served, right, served != right))
else:
store[key] = (right, n)
steps.append(Step(n, name, text, key, False, None, right, right, False))
return steps
def percent(part: int, whole: int) -> int:
"""Whole percent, rounded half up, in integers so that the widget's JavaScript prints the same digits."""
return (200 * part + whole) // (2 * whole)
def totals(steps: list[Step]) -> dict:
hits, leaks, n = sum(s.hit for s in steps), sum(s.leak for s in steps), len(steps)
return {"requests": n, "hits": hits, "leaks": leaks, "rate": percent(hits, n)}
# --- the prompt cache and the wait before the first word ------------------------------------------------------
INSTRUCTION = 900 # tokens that start every prompt of the shop's bot, the same for everyone
NOTE = 100 # tokens of the user's own note, right after them
TAIL = 20 # tokens of the new question, last
TARGET = "Mill Lane" # the street in User A's note
GUESSES = ["Elm Road", "Mill Lane", "Fern Close"] # what User B tries; the middle one is right
RIGHT = GUESSES.index(TARGET)
QUEUE_MS = 60.0 # mean of the random queueing delay added to every wait, in milliseconds (a toy figure)
TRIES = 25 # waits measured per guess in one experiment
EXPERIMENTS = 2000
SEED = 2026
def prompt(street: str) -> list:
"""Token labels, equal where the text is equal: the shared instruction, the note about a street, and a fresh question."""
return [("sys", i) for i in range(INSTRUCTION)] + [(street, i) for i in range(NOTE)] + [("question", i) for i in range(TAIL)]
class PromptCache:
"""Stored starts of prompts. A start is kept under a salt; a request reads a stored start only under the same salt,
and only if its prompt begins with the whole of it (lesson 8's rule)."""
def __init__(self):
self.starts: list[tuple[str, list]] = []
def store(self, salt: str, start: list) -> None:
self.starts.append((salt, start))
def reused(self, salt: str, prompt_tokens: list) -> int:
return max((len(s) for t, s in self.starts if t == salt and prompt_tokens[:len(s)] == s), default=0)
def wait_ms(total: int, reused: int) -> float:
"""Best-case time to read the tokens that are not reused, from lesson 23's model at the chip's peak, in milliseconds."""
return prefill_sim.prefill(total - reused).seconds * 1000
def ideal_waits(salted: bool) -> list[float]:
"""The wait of each of User B's three guesses, with no random delay, right after User A sent its note.
Not salted: both users share one key space. Salted: each user's prompts are stored under that user's name."""
cache = PromptCache()
cache.store("A" if salted else "", prompt(TARGET)[:INSTRUCTION + NOTE])
waits = []
for street in GUESSES:
tokens = prompt(street)
waits.append(wait_ms(len(tokens), cache.reused("B" if salted else "", tokens)))
return waits
def timing(salted: bool, seed: int = SEED, experiments: int = EXPERIMENTS, tries: int = TRIES) -> dict:
"""User B picks the guess with the shortest wait, either from one try per guess or from the average of `tries`.
Each experiment draws fresh delays; the same seed gives the same draws in both modes, so only the key differs.
The cache is as User A left it before every try (a real request would fill the cache itself)."""
rng, ideal = random.Random(seed), ideal_waits(salted)
one = many = 0
sums = [0.0] * len(ideal)
for _ in range(experiments):
waits = [[w + rng.expovariate(1 / QUEUE_MS) for _ in range(tries)] for w in ideal]
first = [w[0] for w in waits]
means = [sum(w) / tries for w in waits]
one += first.index(min(first)) == RIGHT
many += means.index(min(means)) == RIGHT
sums = [s + m for s, m in zip(sums, means)]
return {"ideal": ideal, "average": [s / experiments for s in sums], "one": percent(one, experiments), "many": percent(many, experiments)}Show code
"""Print every number the lesson quotes. Run it from this folder: python3 show_numbers.py"""
from leak_sim import (EXPERIMENTS, GUESSES, INSTRUCTION, NOTE, QUEUE_MS, QUESTIONS, RIGHT, SEQUENCE, TAIL, TRIES, ideal_waits, simulate,
timing, totals, wait_ms)
shared, per_user = simulate(False), simulate(True)
print("== The requests (the lesson's table)")
print(" # | user | question | shared: result | per user: result")
for a, b in zip(shared, per_user):
def result(s):
return "LEAK" if s.leak else f"hit (request {s.written_by})" if s.hit else "miss"
print(f" {a.n} | {a.user} | {a.question} | {result(a)} | {result(b)}")
print("\n== Counts")
for name, steps in (("shared key (question only)", shared), ("per-user key (user + question)", per_user)):
t = totals(steps)
print(f" {name}: {t['hits']} hits of {t['requests']}, {t['leaks']} leaks, hit rate {t['rate']}%")
harmless = [s.n for s, p in zip(shared, per_user) if s.hit and not s.leak and not p.hit]
print(f" hits the shared key got right that the per-user key gives up: requests {harmless}")
good_shared = sum(s.hit and not s.leak for s in shared)
good_user = sum(s.hit and not s.leak for s in per_user)
print(f" hits that served the right answer: {good_shared} shared, {good_user} per user")
print(f" leaked answers by who got them: " + ", ".join(f"{s.n} (User {s.user} got an answer written for request {s.written_by})" for s in shared if s.leak))
public = [i for i, (_, a) in enumerate(QUESTIONS) if "{" not in a]
print(f" questions: {len(QUESTIONS)}; asked in the sequence: {len({q for _, q in SEQUENCE})}; answers with no private fact: question numbers {public}")
print("\n== The prompt cache and the wait before the first word")
tokens = INSTRUCTION + NOTE + TAIL
print(f" prompt: {INSTRUCTION} shared instruction + {NOTE} note + {TAIL} question = {tokens} tokens; the stored start is {INSTRUCTION + NOTE}")
print(f" best-case wait if the start is reused: {wait_ms(tokens, INSTRUCTION + NOTE):.1f} ms; read again from the top: {wait_ms(tokens, 0):.1f} ms")
blocks = wait_ms(tokens, INSTRUCTION)
print(f" with storage in blocks a wrong guess still reuses the {INSTRUCTION}-token instruction and reads {NOTE + TAIL} tokens: {blocks:.1f} ms, a gap of {blocks - wait_ms(tokens, INSTRUCTION + NOTE):.1f} ms to the right guess")
print(f" random queueing delay added to each wait: mean {QUEUE_MS:g} ms; {TRIES} waits per guess; {EXPERIMENTS} experiments; guesses {GUESSES}, right one is number {RIGHT + 1}")
for salted in (False, True):
t = timing(salted)
label = "user in the key (salted)" if salted else "shared key"
print(f" {label}:")
print(f" waits with no delay (ms): " + ", ".join(f"{g} {w:.1f}" for g, w in zip(GUESSES, ideal_waits(salted))))
print(f" average of {TRIES} waits, averaged over {EXPERIMENTS} experiments (ms): " + ", ".join(f"{g} {w:.0f}" for g, w in zip(GUESSES, t["average"])))
print(f" the fastest guess is the right one: {t['one']}% with one try per guess, {t['many']}% with the average of {TRIES} (chance is 33%)")Show code
"""Guards for leak_sim.py: the counts the lesson quotes, and the two promises it makes (the user in the key stops every leak and
every timing signal)."""
import random
import re
from pathlib import Path
from leak_sim import (EXPERIMENTS, GUESSES, INSTRUCTION, NOTE, PUBLIC, QUESTIONS, QUEUE_MS, RIGHT, SEED, SEQUENCE, TAIL, TRIES, USERS,
PromptCache, answer, cache_key, ideal_waits, percent, prompt, question, simulate, timing, totals, wait_ms)
LESSON = Path(__file__).resolve().parent.parent
def test_the_lessons_counts():
"""Guards a table the lesson quotes drifting: 6 leaks in 10 hits with the shared key, none in 5 hits with the user in it. Also guards
prose and figures that drift from the code: the numbers the lesson quotes from `show_numbers.py`, in the sentence, table row or figure label that
quotes it, is rebuilt here from the code's results and must appear in lesson.md or in the figures' text."""
shared, per_user = simulate(False), simulate(True)
assert totals(shared) == {"requests": 14, "hits": 10, "leaks": 6, "rate": 71}
assert totals(per_user) == {"requests": 14, "hits": 5, "leaks": 0, "rate": 36}
lesson = (LESSON / "lesson.md").read_text(encoding="utf-8")
figures = {f.name: " ".join(re.findall(r"<text[^>]*>([^<]*)</text>", f.read_text(encoding="utf-8"))) for f in (LESSON / "figs").glob("*.svg")}
ts, tu, n = totals(shared), totals(per_user), len(SEQUENCE)
right_shared, right_user = sum(s.hit and not s.leak for s in shared), sum(s.hit and not s.leak for s in per_user)
given_up = [s.n for s, p in zip(shared, per_user) if s.hit and not s.leak and not p.hit]
won = [s.n for s, p in zip(shared, per_user) if s.leak and p.hit]
assert len(USERS) == 3 and len(QUESTIONS) == 4 and given_up == [4] and won == [12, 14]
tokens = INSTRUCTION + NOTE + TAIL
hit_ms, miss_ms, block_ms = wait_ms(tokens, INSTRUCTION + NOTE), wait_ms(tokens, 0), wait_ms(tokens, INSTRUCTION)
sh, us = timing(False), timing(True)
chance, gap, spread = percent(1, len(GUESSES)), miss_ms - hit_ms, QUEUE_MS / TRIES ** 0.5
label = lambda s: "LEAK " + str(s.written_by) if s.leak else f"hit {s.written_by}" if s.hit else "miss"
short = ["order", "street", "hours", "gift"]
a, b = USERS[0], USERS[1]
quoted = [
# the opening paragraph, the explanation of each key, and the timing section
f"In a toy run of {n} requests from three made-up customers, the shelf answered {ts['hits']} of them without asking the bot, "
f"and {ts['leaks']} of those answers went to the wrong customer",
f"Over the {n} requests, this key gave {ts['hits']} hits, {ts['leaks']} of them leaks, a hit rate (the share of requests the shelf answered) of {ts['rate']}%",
f"The same {n} requests give {tu['hits']} hits, no leaks and a hit rate of {tu['rate']}%",
f"In the toy a hit waits {hit_ms:.1f} milliseconds (ms) at best and a miss {miss_ms:.1f} ms",
f"the quickest of three guesses is the real one {sh['one']}% of the time, and {sh['many']}% after averaging {TRIES} waits each. Chance is {chance}%",
f"every guess waits the same: {us['many']}% either way",
# Go deeper: the counts
f"The {n} requests come in a fixed order",
f"With the shared key the {n} requests give {ts['hits']} hits, {ts['leaks']} leaks and a hit rate of {ts['rate']}%. "
f"With the user in the key they give {tu['hits']} hits, {tu['leaks']} leaks and {tu['rate']}%",
f"because {ts['leaks']} of its {ts['hits']} hits were wrong",
f"in this sequence the user key does not lose: {right_user} against {right_shared}",
f"It gives up request {given_up[0]}, the harmless hours hit, and wins requests {won[0]} and {won[1]}, which were leaks under the shared key",
# Go deeper: the timing toy
f"Each prompt has {INSTRUCTION} tokens that start every request of the shop's bot, {NOTE} tokens of the user's note and {TAIL} tokens of the new question: {tokens:,} in all",
f"the server stored the first {INSTRUCTION + NOTE:,} tokens",
f"matches only the {INSTRUCTION}-token instruction",
f"so all {tokens:,} tokens are read",
f"{hit_ms:.1f} ms for {TAIL} tokens and {miss_ms:.1f} ms for {tokens:,}",
f"B's guesses all read {tokens:,}",
f"with a mean of {QUEUE_MS:g} ms",
f"One experiment draws {TRIES} waits for each guess",
f"over {EXPERIMENTS:,} experiments, with the fixed seed {SEED}",
*(f"| {g} | {round(x)} | {round(y)} |" for g, x, y in zip(GUESSES, sh["average"], us["average"])),
f"| One try | {sh['one']}% | {us['one']}% |", f"| {TRIES} tries | {sh['many']}% | {us['many']}% |",
f"The gap between a hit and a miss is {gap:.1f} ms, and the delay has a mean and a spread of about {QUEUE_MS:g} ms",
f"so {TRIES} tries cut the spread to about {spread:.0f} ms",
f"{us['many']}% is a blind guess",
# What the toy leaves out: the block-storage sentence, with its own gap
f"a wrong guess would still reuse the shared {INSTRUCTION}-token instruction and read only {NOTE + TAIL} tokens",
f"The toy's model puts those {NOTE + TAIL} tokens at {block_ms:.1f} ms, like the right guess's {TAIL}",
f"would then show a gap of {block_ms - hit_ms:.1f} ms, so the toy's {gap:.1f} ms gap comes from its whole-or-nothing rule",
# the 14 request rows
*(f"| {x.n}. {x.user}: {short[q]} | {label(x)} | {label(y)} |" for (_, q), x, y in zip(SEQUENCE, shared, per_user)),
# Proof it works, and the Check-yourself answers
f"{n}, {ts['hits']}, {ts['leaks']} and {ts['rate']}% with the shared key, and {n}, {tu['hits']}, {tu['leaks']} and {tu['rate']}% with the user in it",
f"{sh['one']}% and {sh['many']}% shared, {us['one']}% and {us['many']}% salted",
f"the hit rate falls from {ts['rate']}% to {tu['rate']}% when the user goes in the key",
f"of the shared key's {ts['hits']} hits were leaks, so only {right_shared} were right. The user key has {tu['hits']} hits and all are right: "
f"it gives up one harmless hit (request {given_up[0]}) and wins two (requests {won[0]} and {won[1]})",
f"one try per guess finds the real street {sh['one']}% of the time and the average of {TRIES} finds it {sh['many']}%. With the user in the key both give {us['one']}%",
f"The real guess waits {gap:.1f} ms less, but the random delay of about {QUEUE_MS:g} ms hides that in one try",
f"Averaging {TRIES} waits cuts the noise to about {spread:.0f} ms",
]
missing = [text for text in quoted if text not in lesson]
assert not missing, missing
# the figures: the key, the stored answer and the hit in the two cache figures, and the bars and their numbers in the wait figure
text_a = question(a, 0)
in_figs = [
("shared-shelf.svg", f"1. User {a.name} asks: {text_a}"), ("shared-shelf.svg", f"2. User {b.name} asks: {question(b, 0)}"),
("shared-shelf.svg", f"Key: {cache_key(a, text_a, False)}"), ("shared-shelf.svg", f"Kept: {answer(a, 0)}"),
("shared-shelf.svg", f"Hit. {b.name} gets Order {a.order}, not {b.order}."),
("user-in-key.svg", f"Key: {cache_key(a, text_a, True)}"), ("user-in-key.svg", f"Key: {cache_key(b, question(b, 0), True)}"),
("user-in-key.svg", f"Kept: {answer(a, 0)}"), ("user-in-key.svg", f"Kept: {answer(b, 0)}"),
("wait-by-guess.svg", f"Wait in ms: average of {TRIES} tries, {EXPERIMENTS:,} runs"),
("wait-by-guess.svg", " ".join(g + (" (real)" if i == RIGHT else "") for i, g in enumerate(GUESSES))),
]
missing_figs = [(name, text) for name, text in in_figs if text not in figures[name]]
assert not missing_figs, missing_figs
waits_svg = (LESSON / "figs" / "wait-by-guess.svg").read_text(encoding="utf-8")
averages = [round(w) for w in sh["average"] + us["average"]]
assert [int(w) for w in re.findall(r">(\d+)</text>", waits_svg)] == averages
assert [int(w) for w in re.findall(r'<rect x="190" [^>]*?width="(\d+)"', waits_svg)] == [round(w * 2.4) for w in averages]
def test_a_leak_is_another_users_private_fact():
"""Guards counting a leak wrongly: a leak must be a hit that hands one user another user's order code or street,
and the opening-hours answer, the same for everyone, must never count."""
by_name = {u.name: u for u in USERS}
rng = random.Random(7)
sequence = [(rng.choice(USERS).name, rng.randrange(len(QUESTIONS))) for _ in range(300)]
steps = simulate(False, sequence)
leaks = [s for s in steps if s.leak]
assert len(leaks) > 100
for s in leaks:
assert s.hit and s.served != s.right
others = [u for u in USERS if u.name != s.user]
assert any(u.order in s.served or u.street in s.served for u in others)
assert by_name[s.user].order not in s.served and by_name[s.user].street not in s.served
asked_public = [i for i, (_, q) in enumerate(sequence) if q == PUBLIC]
assert asked_public and not any(steps[i].leak for i in asked_public)
def test_the_user_in_the_key_removes_every_leak_and_lowers_the_hit_count():
"""Guards the lesson's claim for any request order, not only the fixed one: no leak with the user in the key, and never more hits."""
rng = random.Random(11)
for _ in range(40):
sequence = [(rng.choice(USERS).name, rng.randrange(len(QUESTIONS))) for _ in range(rng.randrange(1, 60))]
shared, per_user = totals(simulate(False, sequence)), totals(simulate(True, sequence))
assert per_user["leaks"] == 0 and per_user["hits"] <= shared["hits"]
assert all(s.served == s.right for s in simulate(True))
def test_a_stored_start_is_reused_whole_or_not_at_all_and_only_under_the_same_salt():
"""Guards the timing toy's cache rules (lesson 8's rule, plus the salt): a wrong note reuses nothing, a right note under another salt reuses nothing."""
cache = PromptCache()
cache.store("A", prompt(GUESSES[RIGHT])[:INSTRUCTION + NOTE])
assert cache.reused("A", prompt(GUESSES[RIGHT])) == INSTRUCTION + NOTE
assert cache.reused("A", prompt(GUESSES[0])) == 0 # shares the instruction, but not the whole stored start
assert cache.reused("B", prompt(GUESSES[RIGHT])) == 0
def test_the_wait_shows_a_hit_only_without_the_user_in_the_key():
"""Guards the timing view: shared, only the right guess is quick; salted, all three guesses wait exactly the same."""
shared, salted = ideal_waits(False), ideal_waits(True)
miss = wait_ms(INSTRUCTION + NOTE + 20, 0)
assert shared[RIGHT] < 10 and miss > 50 and [w for i, w in enumerate(shared) if i != RIGHT] == [miss, miss]
assert salted == [miss, miss, miss]
def test_noise_hides_one_try_and_many_tries_find_the_guess_unless_the_user_is_in_the_key():
"""Guards the lesson's numbers: shared key, the fastest of one try is right about two times in three and of 25 tries almost always;
with the user in the key both stay at chance, one in three. Also pins the fixed seed's outcome."""
shared, salted = timing(False), timing(True)
assert (shared["one"], shared["many"]) == (68, 99)
assert (salted["one"], salted["many"]) == (33, 33)
assert timing(False) == shared
assert max(salted["average"]) - min(salted["average"]) < 1 and shared["average"][1] < min(shared["average"][0], shared["average"][2]) - 30Show code
"""Guards for ../widget.html: its data block, what it may show, and that its rules give the same answers as leak_sim.py."""
import json
import re
import subprocess
from embed_widget import WIDGET, config, embed
from leak_sim import EXPERIMENTS, GUESSES, RIGHT, TRIES, simulate, timing, totals
HTML = WIDGET.read_text(encoding="utf-8")
FIGS = sorted((WIDGET.parent / "figs").glob("*.svg"))
# Reads the rules out of the widget between its marker comments, runs them, and compares with the Python answers.
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="lk-config">([\s\S]*?)<\/script>/)[1]);
const js = html.slice(html.indexOf("/* ---------- the toy's rules"), html.indexOf('/* ---------- the page'));
const api = new Function('cfg', "'use strict';" + js + '; return {simulate, totals, percent};')(cfg);
const bad = [];
fx.modes.forEach((m) => {
const steps = api.simulate(m.perUser);
if (JSON.stringify(steps) !== JSON.stringify(m.steps)) bad.push(['steps', m.perUser]);
if (JSON.stringify(api.totals(steps)) !== JSON.stringify(m.totals)) bad.push(['totals', m.perUser]);
});
fx.percents.forEach(([a, b, want]) => { if (api.percent(a, b) !== want) bad.push(['percent', a, b]); });
console.log(JSON.stringify({ modes: fx.modes.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""
# Runs the page script (everything after the rules) against a small stand-in for the DOM, and reads back what a reader would see.
PAGE_TEST = 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 cfgText = html.match(/<script type="application\/json" id="lk-config">([\s\S]*?)<\/script>/)[1];
const code = html.match(/<script>([\s\S]*?)<\/script>/)[1];
function el(tag) {
const e = { tag, children: [], listeners: {}, attrs: {}, checked: false, style: {}, _text: '' };
e.addEventListener = (type, f) => (e.listeners[type] = e.listeners[type] || []).push(f);
e.appendChild = (c) => (e.children.push(c), c);
e.setAttribute = (k, v) => { e.attrs[k] = String(v); };
Object.defineProperty(e, 'textContent', { get: () => e._text, set: (v) => { e._text = v; e.children = []; } });
return e;
}
function open() {
const page = {};
['lk-config', 'lk-live', 'lk-key', 'lk-keyline', 'lk-big', 'lk-detail', 'lk-first', 'lk-looked', 'lk-sent', 'lk-right', 'lk-label', 'lk-grid',
'lk-tnote', 'lk-rows', 'lk-pick', 'lk-why'].forEach((id) => { page[id] = el('x'); });
page['lk-config']._text = cfgText;
const doc = { getElementById: (id) => page[id], createElement: el, documentElement: { setAttribute() {} } };
new Function('document', 'window', 'parent', code)(doc, { addEventListener() {} }, {});
return page;
}
const fire = (e) => (e.listeners.change || e.listeners.click || []).forEach((f) => f());
const chips = (page) => page['lk-grid'].children;
const bad = [];
const want = (what, got, expected) => { if (got !== expected) bad.push([what, got, expected]); };
const start = open();
want('opening: headline', start['lk-big']._text, fx.modes[0].big);
want('opening: first line names the leak', start['lk-first']._text, fx.opening);
want('opening: the leak is marked in the panel', start['lk-detail'].attrs['data-state'], 'leak');
want('opening: chip count', chips(start).length, 14);
want('opening: the switch starts off', start['lk-key'].checked, false);
want('opening: the opening request is pressed', chips(start).map((c) => c.attrs['aria-pressed']).join(), fx.modes[0].pressedAtOpening);
want('opening: timing note carries the run count', start['lk-tnote']._text.includes('2,000 runs'), true);
fx.modes.forEach((m) => {
const page = open();
page['lk-key'].checked = m.perUser;
fire(page['lk-key']);
want('headline, per user ' + m.perUser, page['lk-big']._text, m.big);
want('key line, per user ' + m.perUser, page['lk-keyline']._text, m.keyline);
want('spoken update after the switch, per user ' + m.perUser, page['lk-live']._text, m.requests[1].live);
want('chip labels, per user ' + m.perUser, chips(page).map((c) => c._text).join('|'), m.chips.join('|'));
want('chip states, per user ' + m.perUser, chips(page).map((c) => c.attrs['data-state']).join(), m.states.join());
m.requests.forEach((r, i) => {
fire(chips(page)[i]);
['first', 'looked', 'sent', 'right', 'live'].forEach((k) => want('request ' + (i + 1) + ' ' + k + ', per user ' + m.perUser, page['lk-' + k]._text, r[k]));
want('request ' + (i + 1) + ' is the only one pressed', chips(page).filter((c) => c.attrs['aria-pressed'] === 'true').length === 1 && chips(page)[i].attrs['aria-pressed'] === 'true', true);
});
want('timing rows, per user ' + m.perUser, page['lk-rows'].children.map((r) => r.children[0]._text).join('|'), m.rows.join('|'));
want('timing bar widths, per user ' + m.perUser, page['lk-rows'].children.map((r) => r.children[1].style.width).join('|'), m.bars.join('|'));
want('timing pick, per user ' + m.perUser, page['lk-pick']._text, m.pick);
want('timing reason, per user ' + m.perUser, page['lk-why']._text, m.why);
});
console.log(JSON.stringify({ modes: fx.modes.length, bad: bad.slice(0, 5) }));
process.exit(bad.length ? 1 : 0);
"""
def run_node(tmp_path, name, script, *args):
(tmp_path / name).write_text(script, encoding="utf-8")
result = subprocess.run(["node", str(tmp_path / name), *args], capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stdout + result.stderr
return json.loads(result.stdout)
def label(s):
return f"{s.n} {s.user} " + ("LEAK" if s.leak else "hit" if s.hit else "miss")
def first_line(steps, i):
s = steps[i]
asked = f'User {s.user} asked "{s.question}"'
if s.leak:
return f"Leak: {asked} and got User {steps[s.written_by - 1].user}'s answer."
if s.hit:
return f"{asked} and got the answer saved at request {s.written_by}. It was right."
return f"{asked}. Nothing was saved under this key, so the bot answered and saved it."
def test_widget_data_block_matches_leak_sim():
"""Guards a leak_sim.py edit that was not copied into the widget (run embed_widget.py)."""
assert embed(HTML) == HTML
def test_every_element_the_script_looks_up_exists_inside_the_root():
"""Guards a widget that never starts: a lookup of an id that is not in the page throws in the browser."""
root = HTML[HTML.index('<div class="lk-root">'):HTML.index("<script")]
lookups = set(re.findall(r"\$\('([\w-]+)'\)", HTML[HTML.index("<script>"):]))
assert lookups and all(f'id="{i}"' in root for i in lookups - {"lk-config"}), sorted(i for i in lookups if f'id="{i}"' not in root)
def test_widget_is_small_and_asks_for_nothing_online():
assert len(HTML.splitlines()) <= 300 and len(HTML.encode("utf-8")) < 40 * 1024
page = re.sub(r'<script type="application/json" id="lk-config">.*?</script>', "", HTML, flags=re.S)
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 and "<form" not in page
def test_widget_keeps_the_site_theme_listener_and_palette():
assert "d.type !== 'spicyshark:theme'" in HTML and "e.source !== parent" in HTML
for pair in ("light-dark(#ffffff, #0a0f17)", "light-dark(#ebf0f6, #131b28)", "light-dark(#0a1626, #f4f7fb)", "light-dark(#44566b, #a3b1c4)",
"light-dark(#d2dae4, #1c2635)", "light-dark(#66768a, #7a8a9f)", "--green: light-dark(#00855F, #009E73)", "--orange: light-dark(#b36200, #E69F00)"):
assert pair in HTML, pair
assert "prefers-reduced-motion: no-preference" in HTML
def test_widget_rules_match_the_python_toy(tmp_path):
"""Guards the widget's JavaScript drifting from leak_sim.py: both keys, all 14 requests, every field, and the rounded percentages."""
modes = []
for per_user in (False, True):
steps = simulate(per_user)
modes.append({"perUser": per_user, "totals": totals(steps),
"steps": [{"n": s.n, "user": s.user, "question": s.question, "key": s.key, "hit": s.hit, "writtenBy": s.written_by,
"served": s.served, "right": s.right, "leak": s.leak} for s in steps]})
percents = [[a, b, (200 * a + b) // (2 * b)] for b in range(1, 40) for a in range(b + 1)]
(tmp_path / "fixture.json").write_text(json.dumps({"modes": modes, "percents": percents}), encoding="utf-8")
assert run_node(tmp_path, "parity.mjs", PARITY, str(tmp_path / "fixture.json"), str(WIDGET)) == {"modes": 2, "bad": []}
def test_widget_page_shows_what_the_lesson_names(tmp_path):
"""Guards the part a reader sees: it opens on a leak with its sentence, the headline counts, a word on every request (not colour alone),
the detail of each request in both modes, the sentence spoken to a screen reader after each change, and the timing numbers and bar
widths that follow the switch."""
modes = []
for per_user in (False, True):
steps, t = simulate(per_user), totals(simulate(per_user))
sim = timing(per_user)
avg = [round(w) for w in sim["average"]]
axis = config()["axis"]
modes.append({
"perUser": per_user,
"big": f"{t['leaks']} of 14 answers went to the wrong user. Hit rate {t['rate']}% ({t['hits']} of 14).",
"keyline": "Cache key: " + ("the user plus the question." if per_user else "the question alone."),
"chips": [label(s) for s in steps], "states": ["leak" if s.leak else "hit" if s.hit else "miss" for s in steps],
"pressedAtOpening": ",".join(["true" if s.n == 2 else "false" for s in steps]),
"requests": [{"first": first_line(steps, i), "looked": "Key looked up: " + s.key, "sent": "Answer sent: " + s.served,
"right": f"Right answer for User {s.user}: {s.right}" if s.leak else "",
"live": f"{t['leaks']} of 14 answers went to the wrong user. " + first_line(steps, i)} for i, s in enumerate(steps)],
"rows": [g + (" (the real street)" if i == RIGHT else "") + f": {avg[i]} ms" for i, g in enumerate(GUESSES)],
"bars": [f"{a / axis * 100:.1f}%" for a in avg],
"pick": f"Picking the fastest guess finds the real street {sim['one']}% of the time from one try each and {sim['many']}% from the average of {TRIES}. Guessing blind gets 33%.",
"why": "Every guess waits the same, so the wait tells User B nothing." if per_user else "The real street waits less, because User A’s stored start is reused for User B."})
assert modes[0]["chips"][1] == "2 B LEAK" and sum("LEAK" in c for c in modes[0]["chips"]) == 6 and not any("LEAK" in c for c in modes[1]["chips"])
fixture = {"modes": modes, "opening": 'Leak: User B asked "Where is my order?" and got User A\'s answer.'}
(tmp_path / "fixture.json").write_text(json.dumps(fixture), encoding="utf-8")
assert EXPERIMENTS == 2000
assert run_node(tmp_path, "page.mjs", PAGE_TEST, str(WIDGET), str(tmp_path / "fixture.json")) == {"modes": 2, "bad": []}
def test_figures_are_valid_and_stay_on_the_site_palette():
"""Guards a figure that breaks the site's look or its checks: every colour in a figure is one of the site's, and each has a title,
a description, a viewBox and a white background."""
allowed = {"#0a1626", "#44566b", "#b36200", "#00855f", "#d2dae4", "#ebf0f6", "#ffffff", "none"}
assert 3 <= len(FIGS) <= 5
for path in FIGS:
svg = path.read_text(encoding="utf-8")
used = {c.lower() for c in re.findall(r'(?:fill|stroke)="([^"]+)"', svg)}
assert used <= allowed, (path.name, used - allowed)
assert "viewBox=" in svg and "<title>" in svg and "<desc>" in svg and 'fill="#ffffff"' in svg
assert "<script" not in svg and "href=" not in svg and "<image" not in svg