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.
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 tree:
make sync(=uv run python tools/extract_examples.py --write -o Examples). Examples/also holds files with no Markdown block (hand-written helpers,.idea/,__pycache__).tools/extract_examples.py's check mode (part ofmake 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.make prunedeletes exactly the orphaned ones. 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/. The root
Makefile never enters rust/ and never requires a Rust toolchain, so
verify/gate/all/ci work with no Rust installed. Details: rust/CLAUDE.md.
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 make 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
(each under .claude/skills/) are the other prose passes: figures of
speech become the mechanism they stand for, a passage that makes the
reader cancel one image after another gets restated in positive form, a
sentence the reader must read twice gets its actor named or splits at
the seam, paragraphs get old-before-new order and one topic string, and
every ambiguous "this"/"it"/"which" gets its noun. make rewrite CH=NN
runs these five plus elements-of-style and bruce-edit-apply by
default; make 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; all resolve to DEFAULT_MODEL (Fable 5) today, MODEL_NOTES
there records the A/B evidence behind that, 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.
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 | 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 | read-only, no voice at stake, cheap |
make 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.
/bruce-edit-capture (.claude/skills/bruce-edit-capture/SKILL.md) reads a
diff of Bruce's edits to a chapter, 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 is make verify (fix line endings, refresh #: output markers,
sync, then every gate but the site build). make all is the heavier version:
it also runs every mutating fixer (reflow, the comment-style fixers, import
sorting, blank-line cleanup) before the marker refresh and sync; its ordered
target list lives in tools/run_all.py (ALL_TARGETS), and make all ARGS=--help lists it without running anything. In both, 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. When iterating on one chapter, the manual
sequence is:
uv run python tools/extract_examples.py --write -o Examples# sync committed treeuv run python tools/extract_examples.py# drift check ("In sync")uv run python tools/extract_examples.py --write# (re)build build/examples/uv run python tools/validate_output.py Chapters/NN_*.md##:markers match stdout(cd build/examples && uv run ty check NN_Chapter)# typesuv run ruff check build/examples/NN_Chapter# lintuv run pytest build/examples/NN_Chapter# testsuv run python tools/run_examples.py NN_Chapter# runs scripts, honors norun.txt
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 make verify. make 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.
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, reports and never gates: a sentence links to a
chapter that contains none of the code terms the sentence names. It
finds the real thing (it catches chapter 07's case), and it also fires on
31 sentences whose terms belong to the linking chapter, which a target
has no reason to mention. make self-reference-report reads it, the same
bargain make claims strikes. Do not promote it into the gate without
first getting that count to zero.
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.
- 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. On this machine barepythonis 3.14.6 whileuv run python(andpython3) is the pinned 3.15 beta, and baretyis 0.0.46 againstuv run ty's 0.0.56. Runningvalidate_output.pywith 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. - 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. tools/*.pyis not linted by any gate. Onlybuild/examplesis checked bymake lint/make ci, so atools/script can exceed the 70-char limit with nothing catching it (several already do).tystill matters there; run it directly, e.g.uv run ty check tools/whatever.py.#: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 atmake lint/make 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. - 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," andmake verifyruns during back-to-back gates (including insidemake 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. 2026-08-23:min(timeit.repeat(..., repeat=3))per variant. It flipped again on 2026-08-28 duringmake verify, with the ratio never dropping below 1.07 in twelve standalone runs (six of them under 24 busy loops on 22 cores) or in two whole-bookvalidate_output.pyruns, so it is a rare transient that a burst covering all three sequential repeats (about a second) can produce. Now the two variants are timed alternately, five rounds,minof each, so a burst lands on both. 2026-08-30: the listing (and every other wall-clock boolean) is registered intools/data/timing.txt, so the gate retries a mismatch toward the committedTrueinstead of rewriting it; a flip now surfaces as a loud validate failure after three misses, never as a silent diff in the generated copy. 2026-08-30: 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 quietmake 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. validate_output.pyon the whole tree can leak__del__output between chapters. Itexec()s every block's code against a freshnamespacedict reused as that block's globals. A class defined there forms a reference cycle with its own globals (SomeClass.method.__globals__ is namespace), so plain refcounting never frees it; CPython's cyclic collector runs on its own schedule and can finalize it while a different, later block's stdout is being captured, corrupting that block's output. The fix lives invalidate_output.pyitself: drop the last reference to a block'snamespaceand callgc.collect()(seecollect_now()) right after the block finishes, before moving on. Chapter 10 (Cleanup)'scleanup.pyis the example that demonstrates this (it deliberately relies on__del__timing being unpredictable), so it is the usual trigger if this regresses.- Kindle listings: only the real book is a valid test bed, and
line-leading whitespace is half width there. Send to Kindle (email)
converts a tiny standalone probe EPUB differently from the full book
(probes rendered
prein Bookerly whatever the CSS said; the book renders it monospace), so four probes gave answers the book then contradicted. Test a listing change by building the book with a probe chapter in front (a scratch script that monkeypatchesbuild_epub.book_markdown/epub_css, thenbuild()), never a separate small EPUB. Measured in the book on a Paperwhite: spaces or at the start of a line draw at ~0.54 of a character; the same whitespace after any glyph, even U+200B, draws full width;chis unsupported (zero);6emcame out ~8.4 characters, not 10; a named family beforemonospace("Courier New", Courier, monospace) loses the monospace entirely. Hencelisting_html()prefixes each indented line with​and keeps plain spaces, andCODE_FONTis the bare keyword. Project memorykindle-listing-indentationhas the probe script layout. 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, andmake 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: piping make throughtailswallows its exit code; capture$?or redirect to a log instead. run_examples.pyandvalidate_output.py: never pass a relative--tree. It goes onPYTHONPATHand breaks once an example changes cwd.validate_output.pymanifests this asModuleNotFoundErroron autils/helper (No module named 'greeter'across every block that imports one), which reads as a broken listing rather than a bad flag; an absolute--treefixes all of them at once. GUI/interactive examples are skipped viatools/data/norun.txt(keep those paths current when chapters are renumbered).- Renumbering or renaming a chapter touches, in all four trees
(
Chapters/,Solutions/,Examples/,SolutionsCode/): the filenames, everyNN_*.mdcross-reference and its link text,build_site.pyPARTS,tools/data/norun.txt,tools/data/timing.txt, theREADME.mdtracking table,deep_review_db.md/readability_db.md/bruce_edit_db.md, and anytools/tests/fixture naming a chapter. Appendices use letter prefixes (A_...); build_site labels them "Appendix X". The ones that hide, both inpyproject.toml:per-file-ignoreskeys a few entries by chapter directory ("**/46_Effects--Stateless/exercise_8.py"), so a rename silently drops the waiver and the listing failsI001atsolutions-gate, several steps after the rename looked done; and[tool.ty.environment] extra-pathsnames one (build/examples/06_Foundations--Modules_and_Packages), where a stale entry makestyrefuse to start with "does not point to a directory". Greppyproject.tomlfor the old directory name before runningverify. Renaming the chapter title additionally means the H1, the Solutions H1 (<Title>: Solutions), and every link whose text was the old title. - 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]. A regex that matches chapter filenames must allow-.check_solutions.py'sBARE_CHAPTER_LINKwas\d{2}_[A-Za-z_]+\.mdand stopped matching every chapter the moment the--landed, so the check reported nothing instead of failing. Its own unit test caught that one. Grepre.compilefor\.mdbefore adding a character to a filename. Only[\w./-]is safe in a filename, because that is the character class in all three link regexes (build_site.MD_LINK,build_epub.ANCHOR_TARGET,heading_links.ANCHOR_TARGET). A character outside it fails silently, which is why brackets were tried and rejected: with43_[Functional]_Confidence.md,heading_linksreported "Anchor links OK" for a link to a nonexistent anchor, andbuild_siteemitted un-rewritten.mdhrefs (404s on the site). No gate went red. Also avoid[/]becausenorun.txtandtiming.txtpatterns arefnmatchglobs, where brackets are a character class. - Splitting a chapter silently invalidates every relative cross-reference in
the later half. Nothing greps for prose, so no gate catches this. Splitting
Generators out of Stateless left chapter 46 with fourteen phrases
("the previous chapter", "the previous chapter's second exercise",
"the previous section") that still meant 44, not the newly-inserted 45.
After any split,
grep -n "previous chapter\|previous section\|last chapter"the later half and check each hit against the content it names, since some will legitimately point at the new neighbor. Prefer a named link ([Effect Management](44_Effects--Effect_Management.md#anchor)) over a relative phrase, so the next split fails loudly atheading_links.pyinstead of quietly misleading a reader. Where three references cluster in one section, resolve the later ones with "that chapter" against a nearby link rather than repeating the same hyperlink. - 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. make helpis self-documenting, not hand-written. A target needs a trailing## textcomment on its own line (and to sit under the right##@ Nameheading) or it will not appear inmake help. Baremakeandmake helpboth list every section;make help stylelists one section. In a terminal both opentools/help_picker.pyinstead (arrow keys or mouse, Enter runs the target,?shows the target's full help: the#comment block directly above it in the Makefile plus its recipe, so keep that block adjacent to the target line, with no blank line between;prompt_toolkit, a dev dependency); a pipe,CI, or--pick nevergets the static text, so runningmake helpfrom a tool or a test never blocks on input. A section's slug is the first word of its heading, lowercased, so renaming the heading renames the slug with no second list to update. Two rulesmake_help.pyenforces by raisingSystemExit: no two sections share a slug, and no slug equals a target name (the Makefile neutralizes the word afterhelpsomake help styleparses as one goal, and a colliding slug would override that recipe). This is why the sections are headed "Code examples" and "Writing and spelling" rather than "Examples" and "Prose": both of those are targets. A##-comment instead of##marks a target secondary: still documented and still smoke-tested, but folded out of the listing because a sibling's doc text names it (everyfix-*under its check). Keepentries()reporting secondary targets, sinceverify_targets.pyenumerates through it andsweep_checks.pylooks up doc text through it. Parsed bytools/make_help.py, deliberately notgrep/awk, since GNU Make on Windows can fall back tocmd.exeasSHELLwhen no POSIX shell is on PATH.tools/README.md's own "Commands" section deliberately does not re-list every target either (it did once, and went stale); it shows only the everyday few and points tomake helpfor the rest. Don't re-expand it into a full manual copy.- 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. tynarrowsstrtoLiteral[...]as of 0.0.63, so thecast()that used to be required at a boundary function is now flagged as aredundant-castwarning and fails the gate.if char not in SPECS: raise KeyError(char)(whereSPECSis keyed by the literal type) is enough;return charthen satisfies the declared return type. Chapter 35'sto_symbol()andSolutions/35_Patterns--Flyweight.mdwere written against the older behavior and were fixed when 0.0.63 landed. The boundary-function idiom itself is still right, only thecast()inside it went away. Project memory (typing-construct-hierarchy) has the fuller case study.- A
tyupgrade is a book-wide event, not a tooling detail. Both directions bite. New narrowing power turns a once-necessarycast()or# type: ignoreinto aredundant-cast/unused-type-ignore-commentwarning that fails the gate, and lost inference turns working listings into errors. The 0.0.58 to 0.0.63 upgrade did all of these at once: literal narrowing (ch35 + solutions),frozendictsupport arriving (two of ch03's three ignores went unused),filter(lambda ...)no longer narrowing its element type (ch16'smap/filterlistings broke), and higher-order union subtraction starting to work (the caveat that invalidated has since been removed from the text). The 0.0.70 to 0.0.75 upgrade added two more shapes: a newmissing-slotcheck that misfires on typeshed'sweakref.finalize(its writableatexitproperty is modeled as a plain attribute absent from__slots__; ch10'sfinalize_trap.pycarries the# type: ignore), and dict keys inferred as literal class objects no longer accepting atype(e)lookup (fixed by annotating the dict explicitly,Final[dict[type[Expr], int]], in Solutions ch34). Aftermake tools-upgrade, runuv run ty check build/examplesanduv run ty check build/solutionsbefore assuming the first failure is the only one:make allstops at the first failing gate andsolutions-gateruns last. The 0.0.75 to 0.0.77 upgrade (2026-09-02, alongside Python 3.15.0b3 to 3.15.0rc2) was the first with no fallout at all:make sweepgreen on both trees, no marker or reflow drift, and all four version-pinned claims below re-probed unchanged. Record the quiet ones too, so the next upgrade knows what a clean one looks like. SweepSolutions/for quoted diagnostics too, not justChapters/. The 2026-09-02 exercise pass found ten staletyquotes, every one of them inSolutions/and not one inChapters/: wrong line numbers, wrong diagnostic codes, wrong message text, and twice a claim built on the wrong wording that inverts the point being taught (seeexercise_review.mdPart 2.1). Earlier upgrade sweeps went throughChapters/and stopped. Nothing gates this:solutions-output-checkvalidates#:markers, and a diagnostic quoted in prose is not a marker.grep -rn "error\[" Chapters/ Solutions/finds all 31 in the book, so the sweep is small once you remember it. Chapters 46-47 also pin four behavior claims to a ty version ("underty0.0.77"): thetype-alias probe (46), the accessor-Unknown, chained-supply()order, and partial-handling claims (47). Re-probe on each upgrade and update those version strings; the alias probe is a scratch generator annotated with atype X = Depend[...]alias whoseyield from need(Undeclared)must still drawinvalid-yield. The other three probe againstbuild/examples/47_*/: bothsupply/catch_allorders must reveal the same union while the two nested forms giveinvalid-argument-typeandno-matching-overload+Unknown;partial_handling.pywith its# type: ignorestripped must still reportGenerator[Need[Log], Any, None]againstrun(); andhandle(scripted)(handle(capture)(greet))must still revealUnknownwhere the named intermediates reveal() -> Generator[Ask, Any, None]and() -> Generator[Never, Any, None]. - 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
make tools-upgradeormake 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.make tools-check[-full],make tools-status, andmake 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. make 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. Usemake 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
make sweepdoes not mean the committed trees are current, and does not mean the#:markers are right.sweepruns gate-checks, solutions-numbering, ty, lint, solutions-ty, solutions-lint, run, test, and solutions-test. It does not runsolutions-check(theSolutionsCode/drift check) or the output validators. Editing aSolutions/*.mdlisting therefore leavesSolutionsCode/stale behind a green sweep, and a stale marker survives too.make verifycovers both, throughsolutions-syncandsolutions-output. When iterating withsweep, runuv run python tools/extract_solutions.pyanduv run python tools/validate_output.py --tree "$(pwd)/build/solutions" Solutionsbefore 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 it reports 9.8 every time, and undervalidate_output.py, which execs the block alongside everything else, it reports the committed 9.9. 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. - 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.make reflow CH=NNstill targets one chapter when iterating. Before writing a script to reflow prose across the book, checktools/reflow_prose.pyfirst: it already masks inline code/links/footnotes, protects an abbreviation list, and greedily packs clauses to fit a width instead of breaking every comma (a naive "break at every comma" script fragments simple lists like "insights, idioms, and patterns" into three lines, a regression, not a fix). ItsSINGLE_LETTER_WORDSset holds single uppercase letters that are real words ("C", the language) rather than initials like "B."; extend it if a new one causes a missed sentence split. 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/*.pyall have thorough module docstrings; read them before guessing.- The
Makefiledocuments every gate and target (make help). - 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.