This file is loaded every session. It captures how the repo is built and verified,
plus the traps that are easy to rediscover the hard way. Personal writing style
lives in the global ~/.claude/CLAUDE.md; accrued facts live in project memory.
AGENTS.md beside this file is for a reader working the exercises with an
agent: if the person you are helping is solving an exercise rather than editing
the book, read AGENTS.md and follow it instead of this file.
Chapters/NN_*.md is authoritative. Every fenced ```python block whose first line
is a # path/slug.py comment is an extractable example. `Examples/` is generated
from the Markdown by `tools/extract_examples.py`, so:
- Edit the code in the Markdown block, never in
Examples/directly. - After editing, sync the committed trees:
tip sync(=uv run python -m tools.extract_examples --write -o Examplesand the same forextract_solutionsintoSolutions/). Examples/also holds files with no Markdown block (hand-written helpers,.idea/,__pycache__).tools/extract_examples.py's check mode (part oftip check/gate/verify/ci) flags these automatically: a stray file whose name appears nowhere inChapters/is orphaned and fails the gate; one still mentioned somewhere (a real hand-written helper) is referenced and only reported, since deleting it needs a human call.tip prunedeletes exactly the orphaned ones, underExamples/andSolutions/both (autils/helper rename orphans a file in each), and never deletes a Markdown file. A rename or deletion of a book example is the usual cause, so run this after either.
Chapter 18's Rust section has real PyO3/maturin crates under rust/. Only
the rust-* tasks in tools/tasks.py enter rust/ or need a Rust
toolchain, so verify/gate/ci work with no Rust installed. Details:
rust/CLAUDE.md.
A cloud session needs the environment's Setup script before anything
through uv run works. Its source is tools/cloud-setup.sh, whose
header says how to install it, what it installs, and why.
The full deep-review procedure (editing pass, teaching pass, style
audit, prose pass, third-party-library rules, accrued review notes) lives in
the deep-review skill (.claude/skills/deep-review/SKILL.md).
Invoke it for any chapter review request.
/annealing (.claude/skills/annealing/SKILL.md) is the follow-up
settling pass, run after a review is applied: it re-runs the same passes
over the whole chapter but applies the confident findings directly and
discards the rest unreported, with no review file.
/activate (.claude/skills/activate/SKILL.md) is the active-register
pass: it clears tip prose's passive-voice and there-is warnings and
cuts metadiscourse, empty frames, and expletive constructions; new
passive-feeling phrasings Bruce flags accrue in its "Accrued patterns"
section.
/literal, /positive, /straighten, /cohesion, and /antecedents
are the other prose passes.
tip rewrite CH=NN runs these five plus elements-of-style and bruce-edit-apply by
default; tip rewrite ARGS=--list shows the set and the model each
pass runs on. Each pass can name its own model in tools/rewrite.py's
PASSES; MODEL_NOTES there records which model each
runs on and why, and MODEL= forces one model on a run.
Every pass checks any claim it rewrites against the listing it
describes; the 2026-09-01 sweep of chapters 30-47 found nine factual
errors that way, none of them gate-detectable.
CH="25 28" or CH=30-40 runs several chapters in parallel (each chain
edits and checks only its own chapter); ARGS=--serial runs them one at
a time.
tip outside-review CH=NN (tools/outside_review.py, 2026-10-08) sends
a chapter to Gemini through the Antigravity CLI (agy, signed in once
by hand on this machine) with the instructions in
tools/data/outside_review_prompt.md, and saves the reply as
outside_review/<stem>.md, tracked like deep_review/. The reply is a
set of observations, never instructions: the session tests each item
against the chapter and a run under uv run before editing, applies
what passes, and appends a ## Verdicts section to the review file,
one line per item, applied or rejected, with the evidence and the
commit. The first run's five items were all wrong for Python 3.15
(the prompt now lists the 3.12-3.15 features the book uses), and the
corrected run's third item claimed a TypeError that type.__new__()
never raises, so the test is not optional. The reviewer has no tools:
a tool call ends its run with an empty reply, which the script reports.
The session model (whatever /model set) reads the request, plans,
gates, and commits. It does not do every kind of work itself: the
project defines agents in .claude/agents/ whose frontmatter fixes
the model, and a request that matches one is delegated there, one
agent per file, gated and committed by the session afterward. Add a
row here when a new agent lands.
| Request shape | Agent | Model | Why |
|---|---|---|---|
| clarity pass, straighten, clear passives, "make X clearer", "obscure/unclear sentences" on named files | prose-clarity-opus |
Opus | judgment work that edits the author's voice and verifies claims against listings; both Opus and Fable did it well in the 2026-09-01 Solutions sweep, Opus's reports were the more careful about what they left alone |
| a list, a count, a location, a gate's output, what a listing prints | repo-lookup-sonnet |
Sonnet | read-only, no voice at stake, cheap |
tip rewrite passes |
(headless claude -p) |
per pass, tools/rewrite.py PASSES |
see MODEL_NOTES there |
| verify a chapter's factual claims against its listings and against the chapters it names | a fresh agent per chapter, report-only | Opus | verification fails by under-reading, not by over-editing, so MODEL_NOTES' result for the rewrite passes inverts here; in the 2026-09-02 calibration Opus found three real errors Fable read past, with zero false positives from either |
| deep review of a chapter, thread audits, anything that decides what a chapter claims | the session model, or a fork |
session | needs the conversation's context; a fresh agent cannot know what Bruce has already ruled on |
Pass model: on an Agent call only to override a definition for one
run. A fresh agent costs roughly 70-150k tokens on a chapter-sized
file; a fork carries the whole conversation and costs several times
that, so forks are for work that needs the session's history.
When Bruce says he is starting to edit a chapter, run /edit-start NN;
when he says he is done, run /edit-done NN. The skills hold the steps.
The local tag edit-start-NN is the only state (git tag -l 'edit-start-*' lists open passes), and a SessionStart hook prints the
open passes into every session, so a request that names no file ("fix
that sentence about closures") is looked up in the chapter in progress
first, and its Solutions file second, before asking which chapter he
means. "Apply" from him, with a chapter editor page open, means run a
round of the chapter-editor skill.
/bruce-edit-capture (.claude/skills/bruce-edit-capture/SKILL.md) reads the
before/after diff of an editing pass on a chapter, whoever made each edit
(Bruce's rule since 2026-09-28: a pass is judged by whether the chapter got
better), separates the generalizable edits from the local ones, and proposes editing practices into bruce_edit_db.md. It writes
only that file, never Chapters/. /bruce-edit-apply
(.claude/skills/bruce-edit-apply/SKILL.md) applies the promoted rules to a
chapter or the book, reporting per-rule firing counts. The split is deliberate:
capture is cheap and reversible, application rewrites prose that no gate
checks. One sighting logs a candidate; a second sighting in a different chapter
promotes it to a rule; only rules are applied. Rejected rules go to Retired and
are never re-proposed.
Fastest path for one chapter is tip verify-ch CH=NN
(tools/verify_chapter.py): the same fixers and gates as verify,
scoped to that chapter and its Solutions file, in a few seconds. It
reflows the chapter only, since the gate never reflows Solutions/,
and it writes no gate stamp; a change that other chapters depend on
(a renamed listing, a utils/ helper, a linked heading) still needs
the whole-book run. That run is tip verify: fix line endings, every
mutating fixer (the comment-style fixers, import sorting, blank-line
cleanup), refresh the #: output markers in both trees, sync Examples/
and the .py files in Solutions/, build the figure gallery, then every gate but the
site build. Its ordered step list lives in tools/verify.py
(VERIFY_TARGETS), and tip verify ARGS=--help lists it without
running anything. The gate skips work that cannot find anything new
(tools/skip_stamps.py; tools/CLAUDE.md has the policy).
Two verify-ch runs at once wipe each other's build/examples, so
parallel runs (agents, several chapters) add ISOLATED=1 as a tip
variable after the task name, uv run tip verify-ch CH=NN ISOLATED=1
(an ISOLATED=1 environment-variable prefix is ignored and the run
uses the shared tree), which
extracts into build/verify-ch-NN/ and reads only that tree (since
2026-10-05, after a twenty-agent sweep spent 5 to 40 retries per
chapter on the collisions); build/verify-ch-* can be deleted at any
time.
The marker refresh runs
before the sync, not after: gate/solutions-gate refresh markers
too, but only after their own prior sync step already copied the Markdown,
so a marker that needed fixing would otherwise stay one sync behind until
the next run caught it up.
Both gates and verify-ch also run tools/marker_placement.py (since
2026-10-02): a #: run must sit directly after the statement that
printed it, never gathered at the end of a listing. It reports and does
not self-heal; tip marker-placement ARGS=--write moves the runs, and
its docstring has the layout rules (import output goes below the import
block, for ruff's I001).
Prose-only edits still need heading_links.py (cross-references),
banned_phrases.py, and check_self_reference.py (claims the book makes
about its own chapters); all three are in tip verify. So is
check_quoted_diagnostics.py: every quoted ty diagnostic's gutter
lines are compared with the extracted listing, and the dozen quotes
the book deliberately makes against an edited copy of a listing live
in tools/data/quoted_diagnostics_baseline.txt. A NEW entry after a
listing edit is a stale quote to requote, or a fresh deliberate edit
to accept with tip quoted-diagnostics-accept; --all lists every
hit. exercise_refs.py (in the gate since 2026-09-20) does the same
for prose that names an exercise by number: each "exercise N", "the
second exercise", or "the previous exercise" in Chapters/ and
Solutions/ is paired with the Solutions title under that number,
and the pairs live in tools/data/exercise_refs_baseline.txt.
Inserting or reordering an exercise changes the title under every
later number, so every reference to one turns NEW and fails the gate.
Reread each NEW sentence against the title printed beside it, fix the
number or tip exercise-refs-accept, and never accept without that
read: chapter 30 carried five stale numbers for a day after commit
a2cc5a98 inserted an exercise at 2, with every gate green. A new
reference is NEW too, until accepted. tip verify-ch sees only the
references its two files make, so after moving an exercise run
tip exercise-refs over the book. tip verify's gate also
runs validate_output.py --update over all of Chapters/ now, so a stale
#: marker anywhere self-heals (rewriting Chapters/) instead of failing
the build, the same way fix-eol/sync already self-heal other drift.
Check git diff Chapters/ afterward: a chapter you did not touch can
still land in the diff if its output actually changed. An exception
raised where none is expected still fails the gate; only marker text is
auto-corrected. A lone bare #: with nothing after it is always treated
as a not-yet-filled-in placeholder and filled in, even without --update.
Four report-only
prose checks run in tip prose, never in a gate, each with a baseline
of judged keeps in tools/data/ so only new hits print (since
2026-10-03): tip stranded (tools/stranded_prepositions.py, a clause
ending on a preposition whose object moved) and tip watch-words
(tools/watch_words.py, the style guide's don't-use and avoid tiers).
tip internal-names (tools/internal_names.py, 2026-10-07) lists each
public class name, in code under Examples/ and Solutions/, that no
code outside its class and no prose code span uses, a candidate for a
leading underscore; its baseline holds the judged keeps (stored
constructor data, table-driven names, double-dispatch legs,
introspection demos), and record fields are skipped by design.
tip positional (tools/positional_refs.py, 2026-10-07) lists prose
after a listing that points at a line by position ("the second
print()", "the last line", "line 3") or by a construct the listing
holds more than once ("the for loop" with two fors), the shapes
the tag convention replaces; a sentence that already cites a [n]
tag is skipped. Its baseline was seeded from the 2026-10-07 tag sweep,
which read every hit, so a new hit is a pointer to tag or a false
positive to accept (an ordinal naming an argument, a version, a
runtime call, output, or data).
tip stranded-accept, tip watch-words-accept,
tip internal-names-accept, and tip positional-accept add the
current new hits to the baseline after a human read; ARGS=--all
lists every hit.
The 2026-10-03 sweeps read every hit: 87 strandings fixed and 84
accepted, 610 watch words fixed and 220 accepted (a want with a
human subject, an arithmetic even, a never that is the claim, a
heading echoed in link text). Chapter 30's hits were left for its open
editing pass and are kept out of the watch-words and positional
baselines, so they stay NEW until that pass closes.
The block quote under each ## N. heading in Solutions/ is the
chapter's exercise statement, copied by tools/exercise_statements.py
(tip statements, since 2026-10-02, after issue 16). The chapter is the
source: edit the exercise in Chapters/, and tip verify, tip gate, or
tip verify-ch rewrites the quote. A hand edit to the quote is
overwritten on the next run. The tool owns the block quote that starts at
the first non-blank line after a numbered heading, so a solution that
opens with a quotation of its own needs a sentence in front of it. Links
in the copy gain the ../../Chapters/ prefix, and exercise_refs.py skips
the copied lines. A solution's heading and its quoted exercise now sit
together, so read the pair when a solution changes: a solution that
answers a different exercise from the one quoted above it is the drift
check_solutions.py cannot see.
Each chapter with exercises carries one sentence under its
## Exercises heading that links ../Solutions/<its own stem>/,
so a reader on GitHub can click through. check_solutions.py fails a
chapter whose link is missing or names another folder, which a chapter
rename leaves behind; tip fix-solutions-links inserts or corrects it.
The wording is the author's to change; the gate holds the target.
build_site.load_chapter() rewrites the link to the GitHub URL, since
the site, EPUB, and PDF carry no Solutions pages.
Solutions layout (2026-10-02). A chapter's solutions are
Solutions/<chapter>/README.md, the authored Markdown, and the .py
files beside it are generated by tip sync and are never edited by
hand. Tools find the file through tools/repo.py (solutions_file(),
chapter_stem()), not by building a path, because a solutions file's
own stem is README. --prune deletes orphaned .py strays and never
deletes Markdown.
A solution whose section opens (after the generated quote) with a
paragraph beginning Hint: is folded by tools/solution_steps.py
(tip steps, in gate and verify-ch since 2026-10-02) into nested
<details>: "Where to look" (the hint), "The shape" (each listing
with its function bodies elided, generated), then "Solution" (the
rest, as authored). GitHub and the site render the ladder collapsed,
so a reader opens one step at a time; tip hint CH=30 N=3 prints the
same steps one per run. The <details> lines, the three <summary>
labels, and every block starting # The shape of are tool-owned and
rewritten on every run; edit the hint, the prose, and the listings
inside the ladder, never the tags. A solution without a Hint:
paragraph is left flat. Chapter 30 is the prototype; the other 44
files have no hints yet. The site renders each Solutions file as
<stem>.solutions.html beside its chapter (tools/build_site.py,
write_solutions_page()), with solutions.js remembering which
steps a reader opened; the EPUB and PDF still link GitHub. A shape
block carries no #: marker and no path comment, so the extractor
and the marker validator skip it, and the widths check still reads
it.
ty is the only checker the gates run.
Pyright's disagreements with ty live in tools/data/pyright_baseline.txt,
and tip pyright-review prints the delta;
the tool-upgrade skill has the workflow and the history.
Three rules hold everywhere:
- Nothing pyright-related joins
verify,gate,sweep, orci. - No listing carries a pyright suppression. An accepted disagreement is a baseline entry instead.
- "The type checker" in prose means
ty. A sentence that holds fortyalone namestyand says what the other checkers do. - Name
tyonly where the sentence needs that checker (Bruce, 2026-09-29, after PR #15 read`ty` seesas a typo fortry): a contrast with Pyright or mypy, a version pin, atycommand or setting, a diagnostic name, or behavior known to bety's own. Everywhere else write "the type checker". In Part I (chapters 02-10) the firsttya chapter keeps reads "the type checker (ty)"; from chapter 11 on, a baretyis fine.
check_self_reference.py gates the shape that produced the most errors
in the 2026-09-02 correctness sweep: prose asserting something about
another chapter, or about the book, that reading the named place
disproves. Chapter 07 said "[Rethinking Objects] uses it" of
slots=True, and chapter 20 contains the string "slot" zero times. The
link resolved, the anchor was fine, every gate was green.
Two rules gate, because a substring search settles them. absence
catches "X appears nowhere else" where X is used elsewhere, searching
code spans and listings only, since the book's type names are also
English words ("a more complex design" must not disprove a claim about
complex). direction catches an ordering phrase that names a chapter
("an earlier chapter") within 80 characters of a link pointing the other
way.
A third rule, grounding, needs a model's verdict, so a new
misattribution passes the gate until tip grounding-triage runs.
Run it after editing a sentence that links another chapter.
tools/CLAUDE.md has how it works, and how tip link-support,
tools/prose_calibration.py, and tip edit-patterns work and what
their calibration found.
The rules are literal and under-report by design: a claim with no code term in it is invisible to them. They are a floor, not a substitute for reading.
Every naming of a design pattern is written capitalized and in italics,
on every mention, in prose and in link text: State, Chain of
Responsibility, [*Template Method*](25_...). Names like State, Command,
Bridge, and Proxy are ordinary words otherwise, so this is the exception
to the global "italics only to introduce a term" rule. Headings stay
plain, and the lowercase word keeps its ordinary sense ("an observer
registers"). tools/pattern_names.py checks it (tip pattern-names)
and tip fix-pattern-names rewrites the unambiguous cases; the names
and the excluded phrases (!State Machines) are in
tools/data/pattern_names.txt. State/Command/Bridge at a line start
are listed only with --sentence-start, for a human to judge. As of
2026-10-06 it lists four lines in Chapters/ and six in Solutions/,
all the ordinary word: the verb ("State the rule...") in nine and the
noun ("State that changes with each call") in chapter 17. The check has
been in GATE_CHECKS since 2026-09-16, so verify, gate, and
verify-ch fail on a plain name. Since 2026-10-06 the gate runs it
over Solutions/ too: a hint that links a section by its title
italicizes the pattern name inside the link text,
[The Classic *Visitor*](...), and a section title that uses the
word in its ordinary sense ("Intrinsic and Extrinsic State") gets a
! exclusion. A bold step label that opens with the verb
("State the precondition in the strategy.") counts as a sentence
start and joins that listing.
Prose that walks a listing line by line points at a line with a tag,
not with "the first line" or "the for loop" (Bruce, 2026-10-07: a
positional phrase makes the reader count, and a construct name fails
when the listing has two of that construct). The tag is a trailing
comment # [1], # [2], ... on the code line, numbered from 1 in
order within one listing, and the prose refers to it as a code span,
`[1]`, never bare: the EPUB and PDF concatenate the chapters, so
one stray [1]: definition anywhere would turn every bare [1] into
a link. Name the construct instead when the listing has one of it and
the prose mentions it once; tags are for an ordered walk. The tag
counts toward the 60-column width, so a tagged line near the limit is
split (prop = announcing(...) # [3] then setattr(...) # [4]) or
carries the tag on its own line above it, which refers to the line
below. tools/listing_tags.py gates it (tip listing-tags, in
GATE_CHECKS): tags run 1..n in order, every tag is referenced in the
prose before the next listing or heading, every `[n]` resolves
to a tag in the nearest listing above, and a bare [n] in prose is a
finding. Chapter 30's broadcasting.py is the first tagged listing.
# [1] passes ruff as configured and under --preview (pycodestyle's
E262 rejects #1, #(1), and #<1>, every form with no space after
the hash), which is why the bracket form was chosen over them.
A figure is an SVG in resources/images/, referenced as .
Never hand-edit a generated one:
each pattern chapter's story figure, and several body figures, from its module in tools/story_figures/,
and chapter 21's coupling_gallery.svg from tools/coupling_panels.py;
edit the spec and run its tip fix-* task.
Load the figures skill (.claude/skills/figures/SKILL.md) before drawing or editing any figure.
It holds the caption rules, the palette, the arrowheads, and what tip figures checks.
utils/record.py (chapter 18, #record) is
dataclass(frozen=True, slots=True) under
dataclass_transform(frozen_default=True). From that section on, a
frozen data class in a listing is written @record with
from record import record, and the prose noun is "record". Chapters
12, 13, and 15 come before the definition and keep
@dataclass(frozen=True). Since 2026-09-26 the decorator is
dual-form (bare or called, two @overloads, the cls is None test
from chapter 14's optional-parentheses section): @record(slots=False)
is dataclass(frozen=True) without slots, the one option it forwards.
Bruce's rulings from 2026-09-17, revised 2026-09-26:
- A base the book owns gets an empty
__slots__ = ()so its frozen subclasses are records. Chapter 20'sshapes_oo.pyteaches it; chapter 34'sNodeandOperators(and every Solutions copy), Solutions 34'sEntry, and chapter 29'sWhatIWantcarry it without comment. - A class whose base is a library's and declares no
__slots__is@record(slots=False): theAbilityandTimesubclasses in chapters 46-47. So is a listing that reads the instance__dict__or pickles across versions (chapter 36'ssketch_v1.py/sketch_v2.py, Solutions 36'sdrawing_v1.py/exercise_7.py), and those four are listed inrecord_exceptions.txtsince their bases are slotted. The flag makes dropping slots a visible choice; never put a bare@recordon a class and let a base take the slots back. - A class that needs an option
record()lacks keeps@dataclasswritten out:order=True(chapter 22), a weak reference (weak_pool.pyand Solutions 35 exercise 5 are@dataclass(frozen=True, slots=True, weakref_slot=True), one__weakref__slot and still no__dict__, since 2026-09-26), acached_property. - Reword a sentence before keeping a listing long-form for its
sake. A sentence that names
frozen=Trueabout a converted class says "record" instead. The exceptions are listings whose subject isfrozen=True: chapter 20'simmutable.pyandfrozen_leaky.py, Solutions 20 exercise 2, chapter 18'sfrozen_vs_slotted.py. Chapter 22'sstill_a_tuple.py(and Solutions 22 exercise 6) stays long-form too: itsFrozen*classes sit besideorder=Truetwins, and the one added option is the point of the comparison. - A Solutions copy of a chapter class follows its chapter listing.
- Slots make an instance smaller, so a marker that measures memory
moves when a class becomes a record. Solutions 35
exercise_2.pywent from a ratio near ten to near six.
tools/record_check.py gates three shapes (tip records, in
GATE_CHECKS and in the Solutions checks since 2026-09-17): a
@dataclass(frozen=True) or @record(slots=False) whose bases are all
slotted fails unless tools/data/record_exceptions.txt lists it, a
bare @record under a base with no __slots__ fails, and a
record(...) call with any argument but slots=False fails. A base it cannot see (imported from
another listing) draws no finding, so it under-reports by design. The
exceptions file is keyed by chapter name (Rethinking_Objects), not
number, so a renumbering leaves it alone and a chapter rename does not.
Run alone, tip records also fails on an entry that matches nothing.
Its UNSLOTTED_LIBRARY names Stateless's Ability and Time, and
chapter 47 says in prose that Ability declares no __slots__; recheck
both against .venv/Lib/site-packages/stateless/ on a Stateless
upgrade.
Project memory record-sweep-classification has the per-class survey
and how it was measured.
- Listing line length is 60 (ruff
line-lengthplus thewidthscheck incheck_all.py, which also covers fragments and#:markers that ruff never sees). The one sanctioned overflow is a trailing# type: ignorepragma, exempted by both ruff andwidths. A#:marker wider than 60 means the program's own printed output must shrink, not the marker. Wrapped imports use packed parentheses with per-fileI001ignores inpyproject.toml(ruff would otherwise force one-name-per-line); a new over-60 import needs its file added there. A scratch dir'sruffuses the default 88, so line length must be verified againstbuild/examples, not a temp file. - Run
ty/ruff/pytestagainstbuild/examples/(viauv run), never a loose scratch file, or config/imports resolve differently. - Bare
python/ty/pyteston PATH can be a different, older tool thanuv run's. They matched on 2026-09-13 (bothpython3.15.0rc2, bothty0.0.78), but they have diverged before: barepythonwas 3.14.6 whileuv run pythonwas the pinned 3.15, and baretywas 0.0.46 againstuv run ty's 0.0.56. Runningvalidate_output.pywith the older barepythonproduced false failures on 3.15-only syntax (sentinel,lazy import, the PEP 798 comprehension-unpacking chapter) that vanished once invoked viauv run. Always go throughuv runfor anything that executes example code; never assume barepython/ty/pytestmatches it. Barepythonis also a different build of the same version:C:/Python/python.exeis the python.org installer's CPython, with the JIT compiled in (sys._jit.is_available()isTrue), whileuv runuses uv's managed build, where it isFalse.[tool.uv] python-preference = "only-managed"inpyproject.toml(2026-09-17) keeps a fresh clone or worktree'suv syncoff the PATH interpreter; before that, uv's default preference pickedC:/Python, and a scratch worktree reported two JIT markers as changed for that reason alone.sys.versiontells the builds apart: "tags/v3.15..." is python.org, "main, ..." is managed. There is nopython3on PATH: the Microsoft Store app-execution stub that answered to that name (printing "Python was not found" and exiting 9009) was disabled on 2026-09-13, so apython3command now fails as "not recognized". - CPython's small-int cache is wider on the pinned 3.15 beta than the
textbook
-5..256range. Confirmed cached up to at least 1024 on this build. An example meant to show an "uncached" int needs a value safely above that (100000+), not just above 256, or the demo silently proves the opposite of what the prose claims. See project memory (small-int-cache-extended-py315) for the chapter-36 case this broke. #:output markers must equal stdout exactly. For nondeterministic output, round floats (f"{x:.6f}") or printtype(e).__name__instead of a message. A wall-clock threshold boolean is the exception: register it intools/data/timing.txt, andvalidate_output.pytreats its markers as claims, never auto-rewritten. On a mismatch it reruns the block, up to three runs in all, and passes as soon as one matches the committed marker; only a marker that misses every run fails, loudly, and the fix is then a human decision. Any other genuinely nondeterministic listing still self-heals silently under--updateand can thrash between values, so don't assume a marker mismatch is repo drift: run the extracted script directly (build/examples/<chapter>/<file>.py) to check the value is stable, and if it's a timing boolean, add it totiming.txtinstead of accepting the auto-fix.- A
#:marker for output animportproduced cannot hug that import. Markers otherwise sit directly after the statement that produced them, but a marker placed after the last import sits inside the import block, and ruff'sI001("Import block is un-sorted or un-formatted") fails the gate.validate_output.pyaccepts either arrangement, so this surfaces attip lint/tip verify, a step after the edit looked correct. Close the import block with its blank line first, then put the marker below it, directly above the code it precedes. Chapter 6'spackage_only.pywas annealed into the hugging form and broke the build; its neighborsusing_packages.pyandfrom_packages.pyalready had the right shape. - A seeded simulation can still print differently on Linux. Solutions
38's
exercise_8.pyseeds itsrandom.Random, and the gate in the WSL clone rewrote one of its#:readings anyway (0.012on Windows,0.010on Linux, 2026-09-24). The seed fixes the random draws; the C library'scosdoes not agree to the last bit between Windows and glibc, and a chaotic run (a kick that throws a grain across the plate each step) multiplies that last bit by about ten a step until a three-decimal reading moves. The chapter's ownchladni_platemarker, at the default kick, matched on both. The fix was two decimals in that one listing, which both platforms print alike; do not reseed, and do not accept the Linux rewrite, which the next run's drift check then fails on. Agit diffon a simulation marker after a Linux run is this until proven otherwise. - Async timing markers flip silently on Windows timers. A
#:trace that depends on ordering between asyncio deadlines needs wide margins. Chapter 19'stask_group.pycancellation demo with 0.01/0.02/0.03s sleeps let task "a" complete before cancellation landed (b's failure and a's deadline fell inside one timer tick), and the self-healing gate rewrote the marker to contradict the prose. Use roughly 5-10x gaps between competing deadlines (0.01/0.05/0.25), widest where a cancellation must propagate, and treat anygit diffon a timing marker as a red flag to investigate, not drift to accept. - Chapter 19's
gil_threads.pyboolean flips under machine load, and widening its threshold would be wrong.thr > seq * 0.9asserts "threads bought no speedup," andtip verifyruns during back-to-back gates (including insidetip release, twice in a row) rewrote it toFalse, contradicting the prose one line below. Do not widen the band to make it robust: at0.7a genuinely 30%-faster threaded run would still report "no faster," hiding the exact regression the listing exists to catch. The fix is inthread_compare.py'scompare(), which the neighboring I/O listing also uses: the two variants are timed alternately, five rounds,minof each, so a load burst lands on both. The listing is registered intools/data/timing.txt, so a flip fails validation loudly instead of rewriting the marker. Do not shrink the loop to make the script faster. At 200,000 iterations each timed round is ~0.075 s, about five Windows scheduler quanta (~15 ms), so one lost quantum is a 20% error and the boolean flipped inside a quiettip verify; at 1,000,000 a round is ~0.38 s and a quantum is 4%. The ratio itself is ~1.02 at either size, so the margin over 0.9 is thin and only the long measurement absorbs scheduler noise. The script's timeouts under the parallel runner were fixed on the runner side instead:run_examples.py's default--timeoutis 60 s (was 15). - Thousands of live
asynciotasks in one process can wedge Windows'ProactorEventLoopfor every laterasyncio.run()call in that process. Chapter 19'stask_vs_thread_memory.pyused to create and cancel 20,000 tasks. Run standalone (its own process, oneasyncio.run()ever), that's instant. Run throughvalidate_output.py(which execs every chapter's blocks, including every laterasyncio.run()block, in one process), it triggered a storm ofRuntimeError: loop ... is not the running loop, one per orphaned task, that took minutes to print and looked exactly like a hang — theKeyboardInterrupts a human sends to escape it then get misattributed to whatever line happened to be executing next, in that or a later chapter. Bisected the threshold on this machine: 15,000 tasks ran clean, 20,000 didn't. Fixed by droppingTASKSto 5,000, comfortably under the cliff. If a future example needs a large task count again, verify it throughvalidate_output.pyon the real chapter file (not a standalone script run), since only the multi-asyncio.run()-per-process path reproduces this. - On Windows a listing can hang inside
asyncio.run()before a line of its own code runs. The event loop's self-pipe comes fromsocket.socketpair(), which CPython emulates with a loopback TCP connection, and the emulation'saccept()blocks forever when the connect fails silently. Afaulthandlerdump on 2026-09-29 showed one validator worker inside thataccept()and the rest idle; two of twelve whole-book runs hung on an idle machine, chapter 47 each time it was identified. Why the connect fails is not known. Since that datevalidate_output.pywatches each file's process and reruns one that reports nothing in--file-timeoutseconds (120; the log says "no result after 120s; rerunning (2/3)"), andrun_examples.pyreruns a timed-out example once.pytestfails a test after 120 seconds (pytest-timeout,timeoutinpyproject.toml, since 2026-09-29) and prints every thread's stack; a timeout on a test that starts an event loop is this, so rerun it. A hang or a timeout on a listing that callsasyncio.run()is not evidence against the listing. build/is derived and gitignored.extract_examples.py --writenow wipes the target underbuild/first, so a fresh sync is the fix for weird drift or a stale tree. A stalebuild/examples/was behind "phantom" timeouts/import errors.- Windows dir-lock on the wipe. If the persistent shell's cwd sits inside
build/examples/<chapter>, that open handle blocks the rmtree andextract_examples.py --writedies withPermissionError [WinError 32]. Keep the shell at the repo root and run chapter-dir commands in a subshell, e.g.(cd build/examples && uv run ty check NN_Chapter). Orphaned python processes cause the same error: on 2026-09-03, worker/load-test processes left behind by subagents (ProcessPool workers, synthetic-load loops) heldbuild/examples/<chapter>long after their agents finished, andtip verifydied at the extract step. After a multi-agent run that executed listings, checkGet-Process pythonbefore a verify and kill strays rooted in this repo's.venv. Also: pipingtipthroughtailswallows its exit code; capture$?or redirect to a log instead. - Renumbering, renaming, or splitting a chapter touches files and prose that no single gate covers.
Follow the
rename-chapterskill (.claude/skills/rename-chapter/SKILL.md). - Every chapter filename carries its part name, set off by
--:NN_<Part>--<Chapter_Name>.md, as in08_Foundations--Static_Types.md,18_Techniques--Performance.md,34_Patterns--Composite_and_Interpreter.md,42_Functional--Error_Handling.md,47_Effects--Stateless_in_Practice.md. The part name is the one inbuild_site.PARTS, so renaming a part renames its chapters' files.01_Introduction.mdstays bare:PARTSstarts Part I at 02, so chapter 01 belongs to no part. The H1 carries only the chapter name (# Static Types), so the filename and the URL say which part a chapter is in while the book's own title stays short. Filename stem and H1 therefore never match; do not "fix" one to the other. Keep_as the separator right after the number:build_site.pyandcheck_solutions.pyboth pull the chapter number withsplit("_", 1)[0]. The link regexes restrict which characters a filename may use; therename-chapterskill lists them and says why brackets fail silently. - Footnote labels are book-wide. The EPUB and PDF concatenate every
chapter, and pandoc keeps a
[^label]:definition's first occurrence, so two chapters sharing a label show the first chapter's note in the second; the site, one page per chapter, hides it.footnotesinGATE_CHECKS(tools/footnote_labels.py) fails on it since 2026-09-16, after release 0.5.9 went out with chapter 17 showing chapter 11'sparametrizenote. - Anchors: pandoc auto-slugs a heading (backticks/punctuation dropped, but
.is kept). Give headings an explicit{#id}when the auto-slug would be ugly (e.g. anything containingtype[...]or__init__).heading_links.pygates it. - A new third-party dependency may not install on the pinned Python.
requires-pythontracks a bleeding-edge version (currently 3.15, a beta at the time this was written), so a package can lack a wheel for it (source build then fails) or refuse outright (its own installer version-guards). Before committing a new dev dependency: add it topyproject.toml, runuv sync, and if it fails, revert (git checkout -- pyproject.toml uv.lock) and re-sync rather than fighting the build. See project memory for the numpy/numba case and the workaround for illustrating a chapter example anyway. - A
tyor Stateless upgrade is a book-wide event. New inference breaks listings, and lost inference breaks them too. Thetool-upgradeskill (.claude/skills/tool-upgrade/SKILL.md) holds the procedure, the version-pinned probes, and what each past upgrade broke. Follow it after any upgrade, which you never start yourself (see below). - A decorator that registers a class must register what a slotted
dataclass()returns, and undertyit cannot build that class by callingrecord(cls)on itstype[E]parameter.slots=Truemakesdataclass()create and return a new class, since a class's slots are fixed at creation, soEVENTS.add(cls)before the call files a class that no instance belongs to. Chapter 28'stagged_bus.pybuilds first and registersbuilt; the one-word swap toslots=Truewithout that died at import with "Announce: not an @event". The same goes for any registering decorator stacked under@record: it would see the pre-slots class. None exists in the book, andtip recordswould not catch one. Separately,tymistypesrecord(cls)on atype[E]parameter, sotagged_bus.pycallsdataclass(frozen=True, slots=True)(cls)directly; thetool-upgradeskill has the probe to re-run on each upgrade. - A
type X = ...alias's right side is lazily evaluated (PEP 695), so it can name a class defined later in the same file with no string quotes, e.g.type Bins = dict[type[Trash], list[Trash]]aboveclass Trash:. Confirmed both at runtime and underty check. - Effect signatures stay written out in full; don't fold them into
typealiases. Chapters 46 and 47 spell every Effect signature out (47 carries the wrapped five-way union): the union is the information, and it stays visible at the point of use. Onty0.0.70 atype X = ...alias as a generator's return annotation checks the same as the spelled-out form (an undeclared Ability drawsinvalid-yieldthrough the alias), but an inference gained in one release can vanish in another, so re-run the probe on eachtyupgrade (stateless-partial-handling-ty-supportin project memory has it) before trusting an alias there. Chapter 45 has no Effect signature, so this entry does not apply there. - Never auto-run
tip tools-upgradeortip python-upgrade. Both mutate tracked files (uv.lock, and.python-version/pyproject.tomlwithTO=) and can invoke real system package managers (winget/brew). Only run them when the user explicitly asks for that specific run, not to "verify" a change.tip tools-check[-full],tip tools-status, andtip sweepare all safe to run freely (the first two are read-only;sweepwrites onlybuild/). The nag thatgateprints when the tools are stale is a reminder for the author, not an instruction to you: never act on it by running an upgrade. tip gatehides half its failures, and not the half you would guess.solutions-gateis a prerequisite ofgate, so the entire Solutions half runs before gate's own recipe starts. One redgateafter a wide-reaching change therefore shows the Solutions failures and hides everyChapters/one behind them. Usetip sweep(runs every check over both trees, reports all failures, exits nonzero if any failed) whenever the first failure is unlikely to be the only one. A tool upgrade is the standard case, andtools-upgradenow ends with it.- A green
tip sweepdoes not mean the committed trees are current, and does not mean the#:markers are right.sweepruns checks, coupling-panels, solutions-numbering, ty, lint, run, and test, each over both build trees. It does not runcheck(theExamples/andSolutions/.pydrift check) oroutput-check. Editing a listing in either Markdown tree therefore leaves its committed copy stale behind a green sweep, and a stale marker survives too.tip verifycovers both, throughsyncandoutput. When iterating withsweep, runtip check output-checkbefore believing the tree is clean. - A
#:marker that measures memory or time is a claim about the process the gate runs it in, not about a standalone run. Chapter 35'sexercise_2.pyprints atracemallocpeak ratio; standalone its first line reports 6.1 every time, and undervalidate_output.py, which execs the block alongside everything else, it reports the committed 6.2. (BeforeTilebecame a@recordon 2026-09-17 the pair was 9.8 and 9.9; the slottedTileis smaller, so the ratio fell.) The gate's context is the authoritative one. Before "fixing" such a marker, reproduce it the way the gate does, or you will correct a value that was already right. - Appendix B's
check_files.pymarker is a self-check, and the gate will rewrite it without complaint. The listing runs the Effect checker on six of its own files and onutils/result.py, and its#:lines show rows for the three edge functions, their module, andresult.Ok.bind ['Unknown']. The prose says every core function is absent from that output, which is the appendix's claim that the core is pure. An edit to any checker listing (effect_table.py,call_names.py,function_facts.py,local_types.py,infer_rows.py,row_check.py) or to chapter 42'sutils/result.pycan add an unresolvable call: a method on a loop variable, on a record's field, or on an imported constant.validate_output.py --updatethen adds the newUnknownrows to the marker and the gate stays green while the prose turns false. Treat anygit diffon that marker as a finding. The fix is to give the checker a written type (an annotated parameter, a small helper), as the appendix's "four changes" paragraph describes, and never to accept the changed marker.greeting_check.pyandthird_party_stub.pycarry findings in their markers too, with prose tied to each line. - Prose in
Chapters/*.mdfollows Semantic Line Breaks (one sentence per line; a sentence still too wide breaks further at a top-level,/;/:).gate(soverify/all/ci) runsreflow_prose.py --write, so hand-edited prose self-heals (rewritingChapters/) the same way line endings and#:markers do; expect rewrapped lines ingit diff Chapters/after a verify. A paragraph that fails reflow's round-trip check is skipped, reported, and still fails the gate, so a rewrite can never silently change rendered output.tip reflow CH=NNstill targets one chapter when iterating. tynarrows a PEP 661sentinel()parameter imprecisely if the annotation names the genericsentinelclass instead of the specific value.dunder: Sequence[str] | sentinelletstynarrow theis ALL_DUNDERSbranch, but the other branch keeps a bogussentinel & ~ALL_DUNDERStype (some other sentinel value, notSequence[str]), which then failsname in dunder. Naming the specific value instead,Sequence[str] | ALL_DUNDERS, fixes it: the union has only two members, so ruling one out viaisleaves exactlySequence[str]. Seedisplay.pyin chapter 17 (Metaprogramming); project memorytyping-construct-hierarchyhas the fuller case study.- Every class, even an empty one, carries compiler-generated dunders that
always differ from
object's own (__module__,__dict__,__firstlineno__,__annotate_func__,__static_attributes__,__weakref__,__doc__). A filter meant to report "dunders this class redefined," built by comparing each dunder toobject's version, must restrict that comparison to a known allowlist (chapter 17'sINTERESTING_DUNDERS) or it leaks all of this bookkeeping as false positives.__static_attributes__itself is new in CPython 3.13+: a tuple of names assigned viaself.Xanywhere in the class's own methods.
tools/is a package: run a tool asuv run python -m tools.<name>from the repo root, not aspython tools/<name>.py(a bare script cannot import its siblings, which aretools.config,tools.repo, and so on). Thetools_prefix the shared modules used to carry is gone; the package namespace does that job.tools/README.mdexplains.tools/*.pyall have thorough module docstrings; read them before guessing.- A listing name in backticks in the prose (
registry.py) becomes a link to that listing in the site and the EPUB at build time (tools/listing_links.py). Never write those links by hand inChapters/; a plain code span is the source form. tipreplaces make:tools/tasks.pyholds every task (tip help),tools/tip.pyruns them, anduv run tip ...works with no install.- Detailed conventions and decisions are in project memory (
MEMORY.mdindex). thinking-in-python-skill.md(repo root) and.claude/skills/thinking-in-python/SKILL.mdare duplicate copies of the same Python coding-style skill, not a symlink (this repo hascore.symlinks = false). Edit one, then copy the change into the other; nothing enforces sync automatically.