# Mechanisms

How this codebase solves recurring problem shapes. Before proposing a design, check whether an entry already fits — the catalog is keyed by problem shape, because that is what you have in hand when searching.

Entry format: an `## UPPER-KEBAB-SLUG — one-line slogan` heading, then **Problem shape** (the situation that should trigger recall),
**Contract statement** (one or two sentences stating what the mechanism promises — the citable line), **How it works**, **Lives in**, and **Reach for it when** (the tell-tale sign you should be using this instead of inventing something).

The Contract statement is citable under the same discipline as rules: a committed comment making a mechanism claim — including any claim about which stage or layer does something — cites the slug and quotes a verbatim excerpt (`# mechanisms.md#SPANS: ...`), checked by the citation-integrity test. Free-prose restatements of mechanism claims are how one wrong sentence has shipped six times.

This catalog converts discovery into a one-time cost — it does not remove discovery. What nobody knows yet still has to be found the hard way, once; the promise is that found things stay found.

## SPANS — position is identity, text is not

Problem shape. A later stage needs to refer to "that word." Contract statement. Every token carries its (start, end) character span in the original string, and stages refer to tokens by index, never by searching for matching text. How it works. v1 re-found pieces by value, so a name with a repeated word could rewrite the wrong occurrence (#100 and relatives); a position cannot be confused with a look-alike. Every parsed part is an exact slice of the input (rule T1's Background). Lives in. nameparser/_types.py (Span), threaded through every _pipeline/ stage. Reach for it when. New code is about to do `if token.text == ...` to LOCATE rather than to classify.

## FOLDED_TAG — reorder at render time, not parse time

Problem shape. A rule wants words to RENDER in a different order than they sit in the string. Contract statement. Tokens never move: a rule that needs different rendering order tags the token, and the rendering views consult the tag — EVERY view that renders the affected role, ordering folded tokens first, not the field views alone. How it works. Reordering the token tuple would break span math and reintroduce the #100 family. Parse state stays in string order; only the view reorders (rule R1, rule R3's order clause, rule O3's render clause). The consumer list is where this mechanism fails, in two opposite directions — and only ONE of them has ever shipped, which is worth keeping straight because a roster is walked differently when it is guarding against a defect than when it is guarding against a possibility. SHIPPED: a consumer that never read the tag (`initials()` walked written order through 2.0, 2.1 and 2.2 — #408, filed as an instance of RENDER-HONORS-THE-PARSE and fixed 2026-08-30). HAZARD, never released: a consumer that reads the tag where it should not (the revise strip below), which arrived with `Parser.revise` in the same commit as the test that pins it, so no version has gone out without it and no issue, decision entry or test names a defect of that shape. Adding a producer is cheap; adding a view is where the roster has to be walked. Lives in. nameparser/_types.py (FOLDED_TAG, `_text_for` — every role it renders, not FAMILY alone), nameparser/_render.py (`initials`, the same partition per role), nameparser/_facade.py (`_list_for`, the v1 `*_list` views, which prepend the carriers the same way), nameparser/_pipeline/_post_rules.py (its two producers, O3's fold and P6's attachment, both of which re-role to FAMILY — so FAMILY is the only role a parse can put the tag on today, and the views partitioning every role is uniformity with this contract rather than reachable behavior), and nameparser/_parser.py (`Parser.revise`, the one deliberate CONSUMER-side strip: it removes the tag from the tokens it harvests, a revised value not being allowed to inherit fold ordering) — losing that strip is this mechanism's measured hazard, a family rendering "García Gabriel Márquez". A module path and not a method name, because this roster is what a reader walks when adding a view, and a bare `Parser.revise` sends them looking in the wrong file. Reach for it when. A new rule needs "X renders before Y" and you are tempted to swap tokens. Don't swap. Tag.

## VOCAB-TAGS — the vocabulary layer speaks once

Problem shape. A later stage needs to know what the vocabulary knew about a word. Contract statement. classify tags every token with what the vocabulary knows about it, and later stages test tags — they never re-look a word up. How it works. One lookup site means one answer: a stage that re-derived vocabulary facts could disagree with the stage before it. Stable tags ("particle", "conjunction", "initial") are API;
"vocab:"-namespaced ones are not.
Lives in. nameparser/_pipeline/_classify.py (producer); consumers throughout _group/_assign/_post_rules, and nameparser/_render.py, which is not a stage but reads the same tags to decide what a view shows (#458 moved case repair's conjunction test onto the tag; initials() had read them since 2.0). Reach for it when. A stage is about to import Lexicon to ask about a word classify already saw — or a view is, which is the harder one to notice, since a view legitimately holds a Lexicon for the questions classify never answered; the view side is RENDER-HONORS-THE-PARSE, which owns it and covers the decisions this entry does not produce.

## PIECES — joining structure survives assignment

Problem shape. A rule needs to know how words were JOINED (chained titles, particle groups), not just what roles they got. Contract statement. group records the joining structure as pieces — runs of token indices per segment — and that structure remains readable after roles are assigned. How it works. Roles alone lose the grouping ("who chained with whom"); #359's fix made the particle fold read the opening PIECE rather than the assigned role, which is what makes rule P1 hold under every name_order. Lives in. nameparser/_pipeline/_group.py (producer), _pipeline/_state.py (ParseState.pieces), _post_rules.py (reader). Reach for it when. A rule keyed on assigned roles behaves differently under different name_order values — the stable thing to read is usually the structure. Known limit, found by #395: the structure survives ASSIGNMENT, not group's own later merges. The prefix chain absorbs a joined piece into a longer one, so a rule reading pieces after that point can no longer see the join — see UNIT-PARTITION.

## UNIT-PARTITION — count the units the joining rules built

Problem shape. A rule counts "one name word", but the input holds words that another rule has already joined into one name — and the joining structure it would read has been merged away. Contract statement. Three rules build multi-word units: a particle chain (P2), a conjunction join (P3), and a bound given-name pair (P5). A rule that counts name words counts those units, and takes each whole or not at all. How it works. group builds each join as a piece, but its own prefix chain then merges the joined piece into a longer one, so PIECES no longer carries the boundary — the units are rebuilt from the tags the vocabulary layer left (`particle`, `conjunction`, `vocab:bound-given`). The rebuild is RECURSIVE: what a conjunction or a bound word joins is the next UNIT, not the next word, and absorbing a single token instead strands a particle severed from the words it chains. Note the two joins arrive here for opposite reasons — the conjunction join was built and then swallowed, while the bound-given join was never built at all (P5 joins only at the first non-title piece), so restoring piece boundaries in group would fix the first and silently split the second. Lives in. nameparser/_pipeline/_post_rules.py (`_unit_end`, `_units`); rules.md P1 is the counting rule, P2/P3/P5 the joining ones. Reach for it when. A rule says "one name word" and the input can contain a join — enumerate the joining rules out of rules.md rather than the ones you remember.

## MARK-DONT-STRIP — record the decision, keep the fact

Problem shape. A stage concludes that a vocabulary fact is not operative for one occurrence — a particle that joins nothing, a middle that will render as family. Contract statement. It MARKS the token with a namespaced marker rather than removing the vocabulary tag: the tag says what the word IS, the marker says what was decided about it, and consumers ask for the pair. The cost, which has to be paid in the docs rather than avoided: where a stable tag was documented with a RECIPE — `particle` combined with Role.FAMILY "for actual family particles" — the marker makes the recipe diverge from the view it reproduced, so the recipe's documentation has to say so. How it works. Stripping is tempting because every downstream consumer becomes correct for free, with no predicate to change; it pays for that by destroying the record, and asymmetrically. `particle` is the only tag 31 of the 33 never-given particles carry, so stripping it leaves nothing on the token, while every ambiguous one still carries `vocab:particle-ambiguous` (the two exceptions, `mc` and `vd`, carry `vocab:suffix` — the same dual membership P6's S2 precedence turns on) — the half where a later fork is most interesting is the half that loses its evidence, and ParsedName has no lexicon to re-derive from. Marking also leaves a POSITIVE predicate: "this token used to have a tag" is not testable, `UNJOINED_TAG in tags` is, which is what an ambiguity emitter needs (#405). And `particle` is documented stable API meaning "a word from the particle vocabulary wherever it lands", so removing it would be a breaking change as well as a lossy one. Lives in. nameparser/_types.py — FOLDED_TAG (O3's fold, P6's attachment) and UNJOINED_TAG (R2's standalone particle), both namespaced and internal, qualifying stable tags that stay put. Reach for it when. A rule decides a word is not doing the job its vocabulary implies.

## STRUCTURE-GATES — comma shape as an explicit state

Problem shape. A rule should fire only under one comma convention. Contract statement. segment decides the comma structure once (NO_COMMA, FAMILY_COMMA, SUFFIX_COMMA) and every later stage gates on that single decision rather than re-inspecting commas. Lives in. nameparser/_pipeline/_state.py (Structure), _pipeline/_segment.py (the one decider). Reach for it when. New code is about to count commas.

## TWO-LAYER-ASSIGN — vocabulary claims, position takes the rest

Problem shape. Where should a new "recognize X" behavior live? Contract statement. A vocabulary layer first claims words for what they ARE, wherever they sit; a positional layer then reads every unclaimed word by where it STANDS. Every rule belongs to exactly one layer — with one named exception: the leading-abbreviation shape (rule H2) fires before the suffix vocabulary is consulted, so
"Esq. Smith" reads title, not suffix.
How it works. The two layers compose without ordering bugs because the positional layer never overrides a vocabulary claim (rule O4 is the positional layer's contract). Lives in. _classify/_group (vocabulary side), _assign (positional side). Reach for it when. A proposed rule wants a word's identity AND its position at once — split it, or it will fight both layers.

## STATE-OFFSET-CHANNELS — early facts ride the state

Problem shape. A fact known during tokenization matters to a much later stage. Contract statement. A pre-token fact is recorded as offsets on the ParseState (comma_offsets, interpunct_offsets) and consulted later by position — or by presence alone where the fact is name-level, as both interpunct consumers do — rather than re-derived from text. How it works. The offsets survive every intermediate stage untouched; #298's transcription marker rides this channel from tokenize to order resolution (rules T3/W4). Lives in. nameparser/_pipeline/_state.py, produced in _tokenize. Reach for it when. You are about to re-scan the original string in a late stage to rediscover something tokenize already knew.

## PIPELINE-STAGE-CONTRACTS — the ownership map

Problem shape. "Which stage does X?" — asked before attributing behavior in prose, comments, or fixes. Contract statement. Each stage's docstring header declares what it consumes, produces and reads, and ParseState's docstring holds the cross-stage map, pinned by tests/v2/pipeline/test_state.py. How it works. A claim about which stage or layer does something is CHECKABLE — `parse(s).tokens` prints every token's role and tags — so check it before writing it; one plausible attribution sentence once shipped six times wrong (AGENTS.md's stage-attribution note). Lives in. nameparser/_pipeline/_state.py and every stage header. Reach for it when. Writing any sentence of the form "X happens before Y sees it."

## ONE-PREDICATE-PER-QUESTION — one predicate answers it, and every other site calls that

Problem shape. Two stages need the same answer about the same input, and the one that does not own the decision is about to test for it. Contract statement. Where two sites ask the same question, exactly one predicate answers it and every other site calls that one — never a condition written to match it. The predicate belongs to the QUESTION, not to whichever stage decides: it may sit in a leaf both stages import, and for the leading-title test it must, since the deciding stage is assign and group cannot import assign. How it works. A hand-written mirror agrees with its original only until one of them moves, and the drift is invisible in both directions: each site keeps passing its own tests while they disagree about an input neither covers. Five instances, every one found as a defect before it was found as a pattern — #319 lifted the wholly-suffix predicate into the vocabulary layer "so the comma decision and the honorific peel's segment test cannot drift apart"; #401/#421 lifted the trailing-numeral fork out of assign so the bound-given reserve stopped carrying a copy, its hand-written mirror having been falsified in review more than once — the lesson recorded there being that what must be mirrored is assign's WALK, not merely its condition; #425 replaced that reserve's hand re-derivation of the trailing peel with one function over the view the join would leave; #424 moved assign's leading-title test down because group's own `title()` does not see H2's unlisted abbreviations, so `Xyz. van Johnson` chained where `Dr. van Johnson` did not; #429 moved the no-name-segment test down because group asked by segment INDEX where assign asks by CONTENT. The destination follows the LAYER, not the topic: a predicate over token text goes to `_vocab`, one over pieces and tags to `_pieces`. Both are leaves the stages sit on. The piece layer got its own module only in #439 — until then those predicates collected in `_group`, not because grouping owned them but because `_assign` imports `_group` and cannot be imported back, so group was the one place both stages could reach; five had accumulated across four PRs before the module existed. Stage order is this mechanism's limit, and it forecloses the alternative: where the reader comes AFTER the decider, record the answer on the state instead — `ParseState.order` is that shape, "Recorded rather than recomputed downstream, because the two can differ" — which is unavailable whenever the EARLIER stage is the one asking. (The concrete assign→group import that forced the `_group` collection is gone since #439; what remains is the ordering it was a symptom of, and tests/v2/test_layering.py is where the leaf's contract is now written down.) The cost is a second evaluation of the same predicate, measured for #429 at 1.2–2.2% of a family-comma parse and 0% of every other; recording that number was the right answer there over plumbing a state field the two sites would not otherwise share. Lives in. nameparser/_pipeline/_vocab.py over text (is_wholly_suffix; is_trailing_numeral_suffix — the #401/#421 instance, whose only caller since #439 is the shared peel rather than a stage; and maiden_marker_run, the #434 instance and the clearest two-stage case, called by classify over token texts and by extract over a clause's whitespace words, with group reading the tags classify recorded because it runs later) and nameparser/_pipeline/_pieces.py over pieces: is_suffix_piece, is_leading_title, leading_titles, peel_walk, peel_trailing and segment_suffix_reading are called by both stages — the last of those is #430's instance, where THREE readers share one answer, the render join being the third — while is_title_piece and trailing_start are called by group alone — `trailing_start` being the one to know, since it answers where the trailing run begins and is what P2's chain and M2's walk stop at. tests/v2/test_layering.py holds each module's contract, and a piece predicate growing a dependency on a STAGE shows up there as a widened entry. Reach for it when. You are about to write a condition that mirrors, matches or "does what X does" — or you find a comment saying one does. Grep for the other site's predicate and call it instead.

## RENDER-HONORS-THE-PARSE — the parse decides it, the views honor it

Problem shape. A render view needs a fact the parse already settled — whether a word is the conjunction or an initial, whether a particle is acting as one, which word renders first. Contract statement. The parse decides it; the render views honor those decisions and never re-evaluate them. Two directions break that, and each has been found here as a defect: a view RE-DERIVES the answer from the text, keeping its own copy of a pipeline predicate — that one shipped through 2.0 and 2.1 — or a view honors the record and then OVERRIDES it, readmitting what the decision excluded, which is filed and open here. How it works. Re-deriving fails because the two copies stop being the same question long before anyone notices they are two: `_cap_word` re-ran the conjunction-versus-initial decision from the word's spelling against a hand-maintained copy of the pipeline's `_INITIAL` pattern while classify had already answered it and recorded it on the token, and `_classify.py` asks `is_initial()`, the shape test ANDed with a script-repertoire test since #320, where `_render.py` asked the bare pattern, and asked it per WORD of a token's text rather than per token, so `juan e-f smith` repaired to `Juan e-F Smith` (#458). Overriding fails more directly, and this entry's instance of it is OPEN rather than closed: `initials()` honors the `conjunction` tag through `_SKIP_TAGS` and then readmits the token whenever it also carries UNJOINED_TAG, the mark of an all-particle part, where rules.md#R3 excludes a conjunction "even then" — so under a caller's vocabulary that puts a word in both sets the view readmits what the rule excluded, and under `Lexicon.default().add(particles={'y'})` — the lexicon those two readings need, and they hold under no other — `parse("Anh y Van")` initialed `A. y. V.` and `parse("Juan de y")` `J. d. y.` on 2026-08-29. Read those two values scoped to that lexicon or they are simply false: under the DEFAULT vocabulary the same strings give `A. V.` and `J.`, and the second is a rules.md#R3 example line the doc runner asserts. #461 is where that stands, and it is worth reading before re-fixing it: the narrowing was written, measured and BACKED OUT in the same PR, because honoring R3 there cost `initials()` its agreement with `family_base`, which reads that same `de y` as the base — this entry's other shape, arrived at from the render side, and the reason the question moved from the code to R3's clause (decisions.md carries the argument). What to carry away is the DIRECTION and not its verdict here: a view that honors a record and then readmits what the record excluded is overriding a decision it never took, whichever way this one settles. Case repair reads that same token and does NOT readmit it — `capitalized(force=True)` on `Anh y Van` gives `Anh y Van` under that same lexicon, R4 carrying the carve-out in its own words but ON R3's authority — its text reads "being no name word in any part — the carve-out R3 states for initials", so a change to R3's clause reaches R4's TEXT. What does NOT follow, though an earlier wording of this sentence asserted it, is that the two stand or fall together in BEHAVIOR: they have already come apart, over the 25 corpus names carrying a conjunction in the GIVEN group — `parse("john and jane smith").capitalized()` keeps `and` lowercase, so R4's carve-out holds there, while `.initials()` gives `j. a. j. s.`, so R3's does not (decisions.md#R2 carries that population, and rules.md#R3 now says so in its own words). The dependency is textual, and only textual — so the two views disagree today about that token exactly as they did before #461 and the backout restores that disagreement knowingly; only the FORCED call witnesses the repair half, R5's gate refusing a mixed-case name before any of this is consulted. This is the CONSUMER-side rule over the producer-side entries — VOCAB-TAGS records what the vocabulary knew, MARK-DONT-STRIP what a stage decided about it, FOLDED_TAG what order to render in — and a view reads what they recorded, whichever kind it is. It sits where ONE-PREDICATE-PER-QUESTION's stated limit leaves off: where two live sites need one answer they share a predicate, but a render view always comes AFTER the decider, so the answer is recorded rather than shared — on the TOKEN, which is the views' equivalent of that entry's `ParseState.order`, no view being able to see a ParseState at all. Known limit, and the half most easily got wrong next: a token the parse never saw carries no decision to honor, so a view falls back to the vocabulary — the tell is `UNCLASSIFIED_TAG`, which `ParsedName.replace()` stamps when it splices raw text into a field and the facade's v1 pickle load stamps when it rebuilds a name from `*_list` strings. It is NOT untaggedness, since an ordinary parsed name word carries no tags either; and it is NOT `span is None`, which was tried and is wrong in the other direction — span-less means SYNTHETIC, and `Parser.revise()` builds span-less tokens from a full sub-parse whose tags it keeps on purpose, so the span reading overrode exactly the tags `revise()` exists to preserve (`revise(middle='e-f')` repaired to `e-F` where the parse gave `E-F`). A hand-built span-less token is unmarked and therefore classified, which is the same tag-driven default every other view applies. A view can only fall back if it is HANDED a vocabulary, and exactly one is: `capitalized(lexicon=...)`. `family_base` and `family_particles` are properties on ParsedName, whose fields are original/tokens/ambiguities and nothing else, so a spliced field empties the particles view and leaves the base the whole field, with `Parser.revise()` the crossing there too (docs/usage.rst says so where it documents the degradation). `initials()` is the near miss and the instructive one: it is a METHOD, so it looks like it could ask, but its signature is `(spec, delimiter, separator)` and carries no lexicon — a fallback there was written and dropped because it had to GUESS `Lexicon.default()`, and the guess erased a whole field under a caller's own vocabulary (decisions.md#R4). `capitalized()` guesses nothing: it reads the lexicon it was handed, and only defaults to `Lexicon.default()` when the caller passes none, which is the documented meaning of omitting the argument rather than a fallback. Within the one view that can fall back, the fallback is drawn per QUESTION (rules.md#R4's Accepted clause). Whether a word is the conjunction or an initial is a property of the word, which a vocabulary answers alone, so case repair asks it. Whether a part is wholly particles is a property of the whole PART, which the pipeline answers once and records as UNJOINED_TAG. What `_cap_word` cannot do is RE-DERIVE that answer where no word of the part carries a tag — it is handed the whole token's tags and gates the particle conjunct on UNJOINED_TAG, so it is not blind to the part, it simply has no evidence to reconstruct one from — so repair leaves that half to plain particle treatment and rules.md#R4's Accepted boundary records the consequence — a spliced field is not repaired as a parsed one is, with `Parser.revise()` as the supported crossing. A fallback is right only while it answers as the pipeline would, and THAT is held by hand rather than mechanically: test_regex_sync pins the two `_INITIAL` copies to each other and to config, while the repertoire half of the pipeline's predicate (#320) is deliberately not carried across, layering forbidding the import. What that divergence can reach is now nothing observable: it needs a caller-added conjunction written initial-SHAPED in a script that has no initials (`씨.`), and case repair is the fallback's only reader, so the two paths differ by `lower()` versus `capitalize()` over a caseless script — the same string either way. `initials()` used to be the reader that could witness it, and no longer falls back at all. A second limit, recorded rather than closed: `_cap_word`'s PARTICLE conjunct still keys on the lexicon handed to the view rather than on the `particle` tag, so a repair run with a lexicon other than the parse's re-decides a word the parse already read — a name parsed under the default vocabulary, where `parse('juan smith vega')` reads `vega` as the family, repairs to `Juan Smith vega` when `capitalized()` is handed `Lexicon.default().add(particles={'vega'})` instead — the divergence needs the two lexicons to differ, and repairing under the parse's own lexicon gives `Juan Smith Vega`. Making it read the tag moves a boundary rules.md#R4 states in prose, so it is a separate decision and not a cleanup (decisions.md#R4, "NOT DONE"). Lives in. nameparser/_render.py (`capitalized`/`_cap_word`, `_reads_as_conjunction`, and `initials`, which honors tags and never falls back) and nameparser/_types.py (`_text_for` and `UNCLASSIFIED_TAG`, with the `ParsedName.replace()` producer beside it) and nameparser/_facade.py (the v1 pickle load, the SECOND producer of that mark — it is named in this list because a change that follows the list into `_types.py` alone leaves it behind, which is the site test_a_restored_pickle_keeps_v1_conjunction_repair exists to protect), reading what nameparser/_pipeline/ recorded — the mark those views read is recomputed producer-side in `_remarked`, which is deliberately silent about text nobody classified and is right to be. Reach for it when. A view is about to consult a Lexicon, a regex or an exception list about a word the parse already saw — or a view and a field disagree about the same parse. #408 was that second shape, and is CLOSED (2026-08-30): `initials()` walked tokens in written order where the family field applies FOLDED_TAG's ordering, so `parse("der, y van")` gave family `van der` and initials `y. d. v.` on 2026-08-29 and gives `y. v. d.` now, the view reading the tag as `_text_for` does. Worth keeping as the worked instance of the shape rather than deleting with the fix, and worth two notes on how it read once measured. The disagreement was not a judgment call anyone had taken: the FACADE already ordered folded-first through its own `*_list` views, so the core view was out of step with the field, with v1, and with the facade at once, and nothing in 6125 tests touched it. And where the change has a v1 reference at all it RESTORES rather than deviates, which is not what this entry's other instances have been -- but read that SCOPED to the population it was measured over, because an unscoped version of this sentence stood here until 2026-08-30 and overstated in both directions. Only the two DEFAULT-ORDER policies have a v1 reference: v1 had `middle_name_as_last` and no general `name_order`, so 588 of the 660 moving parses -- the two family-first orders -- restore nothing and break nothing, there being no v1 answer to come into or leave (decisions.md#R3 says the same and carries the rest of the measurement). Where the reference does exist the claim is exact and worth keeping: over the 1094-name corpus at the default order, 71 names move under `middle_as_family`, of which 54 return to 1.4.0's answer and none leaves it. And it is a claim about THOSE 71 rather than about every name the fix touches -- this entry's own lead example is the counterexample, `parse("der, y van")` giving `y. d. v.` before and `y. v. d.` after where 1.4.0 gives `y.`, v1 contributing nothing at all for a family that is all particles (rules.md#R2's territory, and a divergence decisions.md#R2 has already decided in favor of). A view that stopped honoring a record had been quietly reproducing a v1 bug that v1 did not have.

## CLAUSE-CONTENT-OVERRULES-DELIMITER — content wins

Problem shape. A bracketed clause should be treated as something other than what its delimiter pair says. Contract statement. extract may inspect a clause's content against the lexicon and, when it matches, mask only the two delimiter spans so the inner content rejoins the main token stream for ordinary downstream parsing. How it works. "Andrew Perkins (MBA)" is not a nickname (rule S1): the parens are masked away and MBA is classified by the normal machinery — reusing the downstream path, so the delimited and bare forms cannot drift. Lives in. nameparser/_pipeline/_extract.py (_suffix_shaped and the inner-span branch). Reach for it when. About to add a second code path that duplicates what the bare form already does — #335's fix is this shape (a _maiden_marked sibling predicate).

## CURATED-VOCABULARY-ALTERNATION — the config already splits them

Problem shape. Two string shapes collide — the same written form means two different things — and no predicate separates them. Contract statement. A curated vocabulary that OMITS the ambiguous entries is itself the separator: membership is the license, and the per-entry vetting reasons live beside the set. How it works. GLUED_HONORIFICS is the exemplar (rule W2): 씨 peels because it can never end a name; 양 stays out because 김지양 is a given name. The set, not a regex, draws the line. Lives in. nameparser/config/suffixes.py (the vetting block). Reach for it when. Arguing that "no regex can separate X from Y" — check whether a config set already splits them by listing one side. The second half of the pattern: to USE the set in a regex, hand-copy the alternation and pin the copy with a sync roster (_HONORIFIC_SOURCES) — the 2.0.0 ledger has done exactly this since
#308, which is why #376's fix was copying its twin verbatim rather
than inventing one.

## RECORDED-ROSTERS — record the answer, don't re-derive it

Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Lives in. tests/v2/test_ledger_guards.py (_CORPUS_CLAIMS, _EXCLUSION_EFFECT, _CROSS_RULE_WINNERS, _SPAN_BEARING_RULES, _HONORIFIC_SOURCES, _LATIN_ALTERNATION_SOURCES), tools/differential/compare.py (_CORPUS_FLOORS), tests/v2/test_facade_cases.py (_CORE_ONLY_IDS). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts.

## LEDGER-RULE-SEPARATION — file order decides, fields narrow by subset

Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Every ledger rule must carry a `name_regex` — since #451 `validate_rules` REJECTS a rule with `fields` and no `name_regex`, and one with neither was already rejected — so every rule sits in ONE tier, the sort is stable, and FILE ORDER decides every contest: the narrower rule must be written first. `fields` narrows a rule by subset; it does not separate rules by sorting. Narrowing by subset is not the whole contract: since #452 a rule's `fields` must EQUAL the union of the diffs it explains, and `compare.py` reports OVER-DECLARED and exits non-zero otherwise — a declared role no diff moves is not inert, it lets the rule keep claiming a name whose diff SHRINKS into the excess (decisions.md#differential-ledger). The ban ends the SHAPE and not the property it enabled: a required `name_regex` bounds nothing by itself, since the only width check is the sentinel probe — measured, `[a-z]` validates and reaches 941 of 1090 corpus names. What changed is that such a rule now carries a `_CORPUS_CLAIMS` reach and digest, so its breadth is visible once at recording time rather than never (#452). The two-tier sort in `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads (measured on all three): it is the defence for a reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture — and its docstring in tools/differential/compare.py says so. How it works. Detail is owned by tools/differential/README.md. The file-order clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). Whether that pair should be separated by a predicate instead of by order is
[#382](https://github.com/derek73/python-nameparser/issues/382). The old #271/#272
slug taboo is RETIRED (#333): the canonical-rule selector that keyed on those substrings is deliberately deleted — rule authors are free to use them in compound slugs — and the surviving rosters select on their own explicit keys (_HONORIFIC_SOURCES and _LATIN_ALTERNATION_SOURCES by named issue strings, _SPAN_BEARING_RULES by exact leading fix(...) tag). Lives in. tools/differential/compare.py, the expected_since_*.toml ledgers. Reach for it when. A ledger rule's behavior seems to depend on where it sits in the file — it does, and the reorder mutation is the test (run twice in #375; it fails _CROSS_RULE_WINNERS). History: #372 (closed) measured the then-existing fields-only rule owning 1639 of 5257 name×field pairs as filed (2026-08-10); #375/#376 then cut its classifier-of-record share sharply, and the residual pair ownership was read as the last-resort tier working as designed rather than a defect — until #451 retired the shape outright (decisions.md#differential-ledger). #372's two proposed mechanical checks were DECLINED with measurements (see decisions.md#differential-ledger), not left open.

## CANONICAL-VOCABULARY-AT-THE-BOUNDARY — one vocabulary at the comparison

Problem shape. Two surfaces name the same concept differently (first/last vs given/family), and a matcher needs to compare across them. Contract statement. Canonicalize at the point of comparison: every compared surface's output is converted to one vocabulary before matching, and the canonical choice is the one the codebase already derives everywhere else. How it works. Teaching the matcher both vocabularies doubles every rule silently; canonicalizing means existing rules keep matching when a second surface joins. The ledger canonicalizes v1 field names to Role's before matching. Lives in. tools/differential/compare.py (_canonical_field, _V1_TO_ROLE). Reach for it when. A second surface joins an existing matcher — without this, every existing rule silently stops matching the new surface, which looks like added coverage.

## VOCABULARY-OVERLAP-AS-PRECONDITION — assert the intersection you stand on

Problem shape. A test's input depends on two config sets intersecting (a word that is both a title and a particle), and a vocabulary edit could quietly unground it. Contract statement. Assert the intersection as a precondition and distinguish the two failure modes: word moved (pick another member; the message prints what is left) versus intersection empty (the code under test is unreachable — delete it rather than repointing the test). Lives in. tests/v2/test_parser.py (test_the_chained_emitter_is_still_reachable). Reach for it when. Any config-coupled fixture — a test input chosen because of what a vocabulary happens to contain.

## CROSS-RULE-OUTCOME-PINS — pin who wins the contest

Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order.

## VOCABULARY-FEEDS-STRUCTURE — a wordlist edit can move the comma decision

Problem shape. Editing suffix vocabulary looks field-local, but the comma-structure decision (rule C1) reads that vocabulary, so a removal can flip which segment is the family name. Contract statement. Suffix-set membership is an input to the structure decision: removing a word from the suffix vocabulary can change a suffix-comma name into a listing-form name, relocating the family. How it works. Measured in the comma-suffix arc: dropping "dr" from the suffix vocabulary flips "John Smith, Dr." from family="Smith" suffix="Dr." to family="John Smith" — three individually correct documented facts composing into a family-name loss. Lives in. nameparser/_pipeline/_segment.py (the C1 decision) reading the suffix sets through _vocab.is_wholly_suffix. Reach for it when. Editing suffix membership for any word that occurs after a comma in real data — check the structure flip, not just the field.

## WARN-AT-THE-CALLER — walk out of the library, don't count frames

Problem shape. A warning should point at the caller's code, but the entry depth varies (constructor, add(), unpickle, dataclasses.replace, the shim snapshot), so any fixed stacklevel lands on library internals for most paths. Contract statement. The warner walks the stack outward until the first frame outside the library's own modules and warns there, instead of counting frames. Lives in. nameparser/_lexicon.py (_warn_dead_entry), and the related but distinct per-read-location choice is in the #293/#354 bridge (decisions.md#3-0-reevaluations). First measured instance of the hazard: the delegated bytes-deprecation warnings shipped with fixed stacklevel=2 and needed the follow-up fix (0044073). Reach for it when. Adding any warning reachable through more than one public entry point.

## LEGACY-STATE-SIGNATURE — subtract legacy defaults only as a complete set

Problem shape. A retired default rides in on old pickles, but a user may have deliberately re-added one of the same entries. Contract statement. Known-dead legacy defaults are subtracted from restored state only when the state carries ALL of them — the complete pre-retirement signature — so real legacy blobs clean up silently while a deliberate single re-add survives round-trips. Lives in. nameparser/_lexicon.py / _config_shim.py (_LEGACY_DEAD_ENTRIES). Reach for it when. Retiring any default vocabulary entry that existing pickles may carry.

## AMBIGUITY-AT-THE-DECISION-SITE — emit where the branch is taken

Problem shape. An ambiguity report should fire exactly when the parse chose between live readings — no more, no less — and the choosing happens in code, not in vocabulary. Contract statement. Emit at the site that takes the branch, not where an ambiguous tag sits; when a fork's two branches are decided in different stages, EVERY deciding stage carries an emitter; and a branch that runs but changes nothing is not a decision and must not report. How it works. PARTICLE_OR_GIVEN fires from assignment for a lone leading particle and from grouping when a title shifts it off the front — for two years only the first site emitted. And keying on
"the code got here" instead of "the outcome differed" once reported
a fork for all ambiguous particles on "Dr. Van Jr.". Lives in. The AmbiguityKind emitters across _pipeline/ (rule A1 is the observable contract). Reach for it when. Adding any ambiguous vocabulary or any new fork — count the deciding sites, then count the emitters.

## MAKE-WRONG-STATES-UNREPRESENTABLE — the house meta-pattern

Problem shape. A convention keeps being violated no matter how clearly it is written down. Contract statement. Convert the consistency problem into a referential-integrity problem: make the wrong state impossible to express, or mechanically checked, rather than merely documented. How it works. Three instances built this documentation system: _CORE_ONLY_IDS replaced an enumerating comment; verbatim-excerpt citations replaced paraphrase; no-boundary: markers replaced silent omission. The executable examples in rules.md are the same move at document scale. Lives in. tests/v2/test_doc_citations.py, tests/v2/test_rules_doc.py, and every recorded roster. Reach for it when. Tempted to write "remember to keep X in sync with Y."

## LOCALE-PACKS-PURE-DATA — packs configure, they never compute

Problem shape. Language-specific behavior needs a home that cannot drift from the core. Contract statement. A locale pack is pure data — a Policy patch and Lexicon ADDITIONS, add-only: a pack unions vocabulary in and can never remove a base entry — applied by parser_for; packs contain no code paths of their own, and each pack's docstring declares its deviations from the defaults. How it works. What is policy for every language (an order constant) is policy, not pack data; packs are lowercase modules exposing uppercase constants; a pack error is wrapped with the pack's code (rule D2). Pure data means a pack can be audited by reading it — and add-only means a bad DEFAULT can never be routed around by a pack, which is why #342-class fixes must change shipped vocabulary. Lives in. nameparser/locales/ (packs), nameparser/_parser.py (parser_for, the one applier). Reach for it when. A language fix wants an if-statement — make it vocabulary or policy in a pack instead.

## FACADE-CONTRACT — HumanName wraps the core; 1.4-warning-free code keeps working

Problem shape. Where does v1-compatibility behavior live, and what may it do? Contract statement. HumanName is a mutable facade over the immutable core: code that runs warning-free on 1.4 keeps working through 2.x with identical results EXCEPT release-log-classified fixes, each verified in the differential ledger (the RFC's compatibility promise carries the same carve-out) — via validating setters, dirty-tracked re-parses, and pickle round-trips — and the facade never calls the v1 parsing hooks it still carries. "Warning-free" describes the code's 1.4 behavior, not a promise it STAYS warning-free: 2.x adds deliberate warnings (the multi-word-vocabulary and field-assignment ones), each release-log-classified, always with identical results. Lives in. nameparser/_facade.py; v1 import paths preserved by nameparser/parser.py and nameparser/config/. Reach for it when. A core change needs a v1-visible behavior — the facade, not the core, is where parity lives (and cite decisions.md#3-0-reevaluations when parity is the only reason).

## CONFIG-SHIM-SNAPSHOT — v1 config mutations reach the core by snapshot

Problem shape. v1 code mutates CONSTANTS at runtime; the core is immutable. Contract statement. nameparser.config re-exports mutable managers whose contents are converted to immutable core objects through a dirty-tracked snapshot: mutations mark the snapshot stale, and the next parse rebuilds it, so v1 mutation semantics survive over an immutable core. Lives in. nameparser/_config_shim.py, nameparser/config/. Reach for it when. Wiring any new v1 config surface — it must go through the snapshot, never sideways into a Lexicon.

## Verification shapes

How to measure in this codebase without fooling yourself. The inert-measurement class — checks that run, print plausible results, and measure nothing — has recurred double-digit times; these shapes are its known antidotes. Convention (AGENTS.md): guard tests SHOULD carry a RECORDED negative control, the _EXCLUSION_EFFECT shape — the answer with the guard off, stored as data. Honest limit, precisely drawn: these reduce the inert-measurement class. Mutation testing reaches part of the wrong-predicate class too — mutating the thing a guard watches exposes a guard that never depended on it (the #329 survivors: deleting the tag check left the suite green while every multi-word maiden clause lost its first word, after adversarial review had passed that code twice) — but a predicate wrong in a way the fixture happens to satisfy still needs adversarial review.

### VERSION-TELL — know who answered

Contract statement. A subprocess that speaks for a pinned version writes its __version__ and __file__ as its first output line, and the caller aborts before comparing anything if either half disagrees with what was requested. Both halves are load-bearing: an editable install reports the tree's version (agreement proves nothing when tree and baseline share a number); a genuine wheel at the wrong version passes any path check. Lives in tools/differential/compare.py (_check_tell).

### GENERATED-SCRIPT-OUTSIDE-THE-WORKTREE — escape the shadow

Contract statement. A worker that must run under a pinned dependency is rendered to a temp directory outside the worktree and spawned by absolute path, so sys.path[0] contains no copy of the package and the inline pin is genuine. Sentinel substitution (@@VERSION@@), not str.format — the worker body is mostly literal braces. Lives in tools/differential/compare.py (_worker_source, _run_worker).

### SENTINEL-SET-OVER-MATCH-CHECK — catching match-everything

Contract statement. A user-supplied pattern is rejected as over-matching by probing it against a small set of inputs sharing no script, vocabulary or punctuation; matching all of them means it targets no behavior family. Measured: `.`, `.+`, `\b` and `[\s\S]` all decline the empty string — the naive probe — and still match every corpus name. Lives in tools/differential/compare.py (_SENTINELS).

### VOCABULARY-EXERCISES-FORKS — pin the branch, not the wordlist

Contract statement. A vocabulary set a caller configures is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one INPUT and not another — never by covering one more member. The unit is the input, not the member, because most forks are keyed on the shape of the surrounding name rather than on which word appeared. Find them by reading the sites that consult the set and enumerating the branches they take, and expect that enumeration to come back empty: `maiden_markers` is read at exactly two sites (`_classify.py`'s tagging and `_extract.py`'s `_maiden_marked`), both plain whole-token membership after `_normalize`, and every shipped member parses to one structural class in bare, bracketed and lone-bracketed shapes with the marker text normalised out — zero member forks. What earns the maiden rows is clause SHAPE: marker first against marker later, a word after the marker against none, a marker a separator divides from the name against one glued to it. Two adjacent things are NOT forks of this set and do not want rows: the substring-versus-token split is `_carries` in test_ledger_guards.py, a test guard deliberately asking a wider question than the parser (tools/differential/README.md says so), and edge-period normalization lives in `_lexicon._normalize`, applying to every token of every vocabulary, so it discriminates spellings rather than members. Only "a member borne as an ordinary name" is member-keyed (decisions.md#vocabulary-collisions), and it has its own rows. The reduction does NOT carry across to delimiter pairs, and the difference is the line to hold: a pair the library SHIPS is caller-visible behavior, and vocabulary a caller configures is data — deleting `("«", "»")` from the defaults reddens three tests, so cases.py's one-row-per-shipped-pair convention is load-bearing and this criterion is not permission to delete those rows. Within the pairs the code does fork, on whether open and close are the same character (N2) and again inside that class, where `WORD_INTERNAL_DELIMITERS` carves the apostrophe out: `Jane Smith"` reports UNBALANCED_DELIMITER and `Jane Smith'` reports nothing. ASCII against fullwidth is not one of them — swapping `( )` for `（ ）` across every corpus name holding a paren changes no field and no ambiguity kind. A row may also earn its place a second way, with no fork behind it: pinning a name a release note advertises BY NAME, which is a promise to a reader rather than a branch in the parser. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number, and it inflates with every entry added, which is the tell that it measures the data. The finding is "this fork has no test". State the argument without present-tense counts over a drifting wordlist — see docs/design/AGENTS.md — and where a count is genuinely wanted, ship the one-liner instead:

```
uv run python -c "
from nameparser import parse
from nameparser.config.maiden_markers import MAIDEN_MARKERS
shapes = ['Jane Smith {m} Jones', 'Jane Smith ({m} Jones)', 'Jane Smith ({m})']
cls = {}
for m in sorted(MAIDEN_MARKERS):
    sig = tuple(tuple((f, (getattr(parse(s.format(m=m)), f) or '').replace(m, '<M>'))
                      for f in ('given', 'middle', 'family', 'suffix', 'nickname', 'maiden'))
                for s in shapes)
    cls.setdefault(sig, []).append(m)
print(len(MAIDEN_MARKERS), 'members ->', len(cls), 'structural classes')"
```

### FORCE-A-DECISION-TABLE — no silent defaults on growth

Contract statement. Where adding an enum member or a file must not silently inherit a default, a local table's key set is asserted equal to the population, so growth fails the suite until someone decides — against a local table, not the constant under test. Exemplar: tests/v2/pipeline/test_vocab.py's per-script initials check; reused for _CORPUS_FLOORS in tools/differential/compare.py, and for the pack registry (test_registry_is_the_pack_contract: every registered pack must ship DEVIATES and its rotator list or the suite fails structurally — decisions.md#deviates-registry). Known gap it exposes: DEFAULT_SCRIPT_ORDERS has no such guard.

### SELF-EXPIRING-GUARD — a decline keyed to a measured defect

Contract statement. A workaround keyed to a third-party library's measured defect carries a canary test that pins the defect itself, so the workaround cannot outlive its reason: when the library fixes it, the canary fails and the decline gets revisited rather than fossilizing. Exemplar: tests/v2/test_locales.py's namedivider shime canary pins 0.4.x cutting 〆木太郎 at offset 1 with confidence 1.0, guarding the adapter's 〆 decline (#303 arc); its comment says to revisit the decline, not delete the test.

### SIMULATE-EVERY-OPTION — price the option space, not the chosen option

Contract statement. Where a fix has more than one plausible shape, every candidate is measured before one is chosen — simulated in-process against the real pipeline rather than implemented — and the choice cites the resulting table. How it works. The seams already exist, so simulating is cheap: `Parser(lexicon=dataclasses.replace(Lexicon.default(), ...))` for a vocabulary question, a pytest plugin recompiling one stage for a code question, a monkeypatched predicate for a guard. What the table buys is the discovery that the candidates differ in ways argument does not reveal — #400's obvious fix, adding `abd` to the wordlist, provably changed nothing on the common three-word shape, and the two vocabulary alternatives each silently lost a credential spelling; the winner was a fifth option the wordlist framing had hidden. The counterexample is the same PR's ledger `fields`, widened to include `suffix` on one reviewer's finding without pricing what else the widening claimed: it took `{family, suffix}` from a neighbouring rule and two further shapes from UNEXPLAINED, diffs in which the rule's own subject does not move, and was reverted. One table would have shown that before the commit rather than after. Reach for it when. A review names a defect and the fix looks obvious — that is when the option space is least examined, and the obvious fix is the candidate whose cost nobody has priced. Not a mechanism with a home module; the technique IS the seams above, which is why it sits here rather than in the catalog.

### TWO-ELEMENT-GROUPS — a partition's mutation check needs something to permute

Problem shape. A test pins an ordering, a sort, a dedup or a partition, and its fixture gives one of the affected groups a single element. Contract statement. Every group a test claims to cover carries at least TWO elements, because on a one-element group a partition is the identity — the assertion holds whether the code runs or not, and the mutation that should expose it passes. How it works. This is the inert-measurement class arriving through the FIXTURE rather than through the predicate, and it hides behind a mutation check that looks conclusive: #408's `initials()` partitions three roles, and its first test carried two GIVEN tokens and one MIDDLE, and its second carried two of each and no FAMILY token at all — a group of ZERO is the identity as surely as a group of one, and looks even less like a hole. Scoping the partition to FAMILY failed that test, which read as the arms being pinned — but skipping the partition for MIDDLE alone passed the entire suite (measured 2026-08-30, in review, at 6128 passed), because `initials("{middle}")` over one token is the same string either way. The tell is that the mutation which fails is not the mutation the test's docstring promises: a per-role claim wants a per-role mutation, one arm at a time, and only then does the fixture's arity show. Reach for it when. A test asserts on the ORDER or MEMBERSHIP of a rendered group and the fixture was written to be small.

### Field notes — the traps themselves

- Enumerate the rules that BUILD a structure; do not recall them. #395's unit walk was written three times in one PR — P2's chain missing, then the conjunction and bound-given branches absorbing one token where the particle branch absorbed a unit, then the suffix stop — and each miss came from listing the joining rules from memory instead of reading them out of rules.md. Both later misses reproduced the very defect the first fix had just removed, mirrored.
- Prefer monkeypatching a module-level helper to re-exec'ing a module: a caller looks a module global up at CALL time, so replacing `_units` or `_name_positions` reaches it and none of the traps below apply. Re-exec is only needed for logic INLINE in a function, and "the thing I want to mutate is not a module-level helper" is a hint that it could be one.
- Re-exec'ing a module redefines the classes that module DEFINES, not the ones it imports — the exec re-runs its own import statements, which resolve through sys.modules. So `is` comparisons break selectively: re-exec'ing `_group.py` yields a second `BoundJoin` (defined there) while `Structure` (imported) stays identical, and `bound_join is not BoundJoin.DISABLED` is then always true, joining a family comma's own segment and reporting 3,150 phantom movers. Rebind what the module defines (`mod.__dict__["BoundJoin"] = real.BoundJoin`) and assert the identity rather than assuming it either way.
- `_pipeline.STAGES` holds function OBJECTS captured at import, so re-exec'ing a stage module leaves the pipeline running the old function and every result is a false negative. Rewire `STAGES` and assert the new function is in it.
- Either route: hold one input the mutation MUST move and one it must NOT, and check both before believing a count. Every trap here produces confident numbers rather than an error.
- Assert which tree you imported, on BOTH sides of a comparison. `python -c` puts CWD on sys.path; a script's own directory holds no nameparser in tools/differential/, so a stray PYTHONPATH outranks the editable install — measured: 89 diffs became 0, exit 0, both tell halves passing, because both sides had become the shadow.
- Release notes describe intent; TAGS are ground truth. Before trusting a changelog cross-reference about what shipped where, run `git diff <tagA> <tagB> -- <file>` — the "1.2.1 pickle shim" claim recurred twice from a changelog line describing a thing that never shipped.
- Never pipe a gate's output. Under zsh, `compare.py | tail` makes `$?` tail's status. Redirect to a file and read the file.
- Mutation-test a new guard before believing it, and mutate the thing the guard watches — a survivor usually means the fixture satisfies the invariant for free.
- Verify the restore, not just the mutation: diff against the pre-mutation copy; do not trust the harness's own restore report.
- Run all the gates, not the ones you remember: ruff runs before mypy and pytest in CI, and each has caught what the others passed.
- Purge __pycache__ between same-length source mutations; stale bytecode makes a changed file measure as unchanged.
- After NARROWING a rule, check the receiver: the names a narrowed rule sheds land on a neighbour, and nothing guarantees the neighbour's prose describes what it inherited — #375 fixed an over-claiming rule and relocated the bug onto its neighbour. Ask also what the old behavior was CONCEALING: #379's attachment removed the input a test used to build an all-particle middle name (#402), and #400's reserve fix exposed the dual-membership count shape #397 names -- twice in one session a fix's real yield was a defect it stopped hiding.
- A skip is indistinguishable from "correctly declined": pytest turns an empty parametrize into a skip, and a filter that widens its own skip set cannot fail. After changing any selection shape, verify the guard still REACHES the code it watches — assert the selected set is non-empty, or force-a-decision on its size.
- A differential corpus cannot evidence behavior keyed to OUT-of-vocabulary shapes: it holds only names someone wrote down, and an unrecognized word is by definition outside the vocabulary — a green run over the corpus proves nothing about such a rule.
- The corpus can be near-blind to a WRITING CONVENTION even where the vocabulary is well covered, and a small honest count then reads as a small blast radius. Measured for rules.md#P6: of 782 corpus names, 245 carry a comma, TWO of those end in a particle, and ONE of the two clears the words-to-spare guard and actually moves — so the Dutch trailing-tussenvoegsel listing the rule exists for is essentially unsampled. Before reporting "N names move", report the size of the population that COULD move; when that is ~1, the number is evidence about the corpus. This note's first wording said "exactly ONE ends in a particle", conflating the population with the movers — the very error it exists to prevent, committed inside the correction. Caught by re-running the count against AGENTS.md's axis 1 rather than by re-reading it.
- A detector that re-implements a rule's grouping will get the grouping wrong. Derive the boundary from the same vocabulary the rule reads, not from the half you happen to be thinking about: walking a particle run over the NEVER-GIVEN set alone (the rule chains through ANY particle) split "de la Vega" after "de la" and reported 50 false movers for #364, where the true count is one. Both wrong answers were plausible and printed cleanly.
- Guard the whole family, parametrize over it: a defect on one of N parallel entry points hides behind a per-example test — three times in one session (a guard on one class of two, a decode hint on 3 of 5 entry points, a sync roster missing 4 copies) — and a {class}×{field}×{bad-value} parametrization is what caught each.
- A growth guard needs calibration, not just existence: benchmark guards that compare n vs 4n catch the quadratic the absolute-time tests are blind to, but calibrate against the WEAKEST signal you must detect and confirm a planted regression fails across repeated runs, not once — a stochastic check "verified" on one sample verifies nothing.
- Mind the optional-extra environment split: a local venv's incidental namedivider makes `if available` branches run PRESENT locally and ABSENT in CI, so a locally-green suite proves nothing about the no-extra path (this broke #337's first landing). Run the decisive check in both states or gate the example.
