# Decisions

The why behind the rules: a lightweight ADR log, keyed by rule ID (`### P2 — <short name>`), by mechanism slug, or by a short slug for cross-cutting and tooling decisions. Section headings carry the key, so every `decisions.md#P2` reference in code or rules.md is a live anchor.

The gitignored planning specs are the working medium and die with their branches; this file is the record. A landing design PR distills its spec's durable residue here (the checklist is AGENTS.md's "Landing a design").

Entry conventions:

- **Dated decision entries**, append-only in spirit: a reversed decision is not edited, a later entry supersedes it. Each entry cites its issue or PR. The date is the DECISION's, never the release's (add "shipped in X" alongside if useful), and git author dates outrank remembered ones — two review rounds corrected exactly these two errors.
- **Harvested entries** (content landed from a session's report) keep their provenance: a "measurements are that session's, spot-checked at landing" framing line, the contributor's measured-vs-remembered markings where they matter, and — the hard-won one — a flagged uncertainty is resolved by reading the source artifact, never by inference from the neighboring argument.
- **`Declined:`** — proposals rejected WITH the evidence that killed them. Resolved-as-no is a decision; without a home for it, the next person re-derives the rejected proposal and its measurement.
- **`Excluded:`** — standing prohibitions with indefinite lifetime, keyed by vocabulary set: entries that must stay OUT of a wordlist, each with its reason. Distinct from Declined because the failure mode differs — nobody re-derives a rejected proposal, but someone sweeping a wordlist ships the excluded entry as a bug.
- **`Open:`** — unresolved questions as issue links with one-line handles. The ISSUE is canonical; this block never restates it. Two keyings: under a rule ID for questions about the rule, and — like `Excluded:` — keyed to a VOCABULARY SET for contested memberships, the category neither `deviates:` nor `Excluded:` covers: the rule is right and a word's set membership is the question (rai in the suffix acronyms, swami absent from the given-name titles). Place the block beside the set's `Excluded:` entries so a wordlist editor meets both.
- **Weighing entries** for contested questions: the options considered, each option's intended effect, and the accepted costs of the option chosen. The costs accepted here are the artifacts rules.md lists under the rule's `Accepted:` consequences; the two link by rule ID.

### A2 — the empty name

- 2026-07 (v2 core, PR #288) — v1 kept parse(".") as first="."; 2.0 empties content-free input instead. The born-empty-ambiguity survival was a review fix in the rc1 arc: an unbalanced-delimiter report must outlive the emptying, or malformed input becomes indistinguishable from blank input.

### legacy-rule-numbers — the old docstring numbering

Before rules.md, the post_rules stage docstring numbered its rules locally, and historical issue comments (#359, #364, #365, #367) use those numbers. The mapping: rule 1 → H1, rule 1b → P1, rule 2 → O1, rule 3 → O2, rule 4 → O3. Older still: bare "#11"-style citations from v1 comments live in the GOOGLE CODE namespace, not GitHub — GC issue 11 is decisions.md#P3's provenance — so a v1-era number that doesn't fit its GitHub issue is probably a GC number.

### P1 — lone particle fold

A lone PIECE is the whole test — deliberately narrower than "a never-given particle is never reported as the given name," which would be false (the over-broad invariant shipped to five sites before #361's review falsified it with three counter-examples). The counter-example set has since shrunk, and its shrinkage is the section's history: "Sir de Mesnil" fell to #367 (titles became transparent); "Juan de la Vega" under family-first — the whole chain in the given position — was called working-as-intended by
#359, contested by #368, and is working-as-intended again as of
the 2026-08-16 entries below. The survivor is the degenerate bare
"de".

- 2026-08 #359 — the opening site is read from joining structure (pieces), not from assigned roles, so the fold holds under every name_order. Before this, the role-only read let "de Mesnil" split under a family-first order.
- 2026-08 #367 — titles are transparent to the fold: "Sir de Mesnil" now reads like "de Mesnil". Fixed by removing the title→particle chain in grouping, not by touching this rule.
- 2026-08-16 (order-precedence keystone; #364, #365, #368) — the stage split is the decision, and the three issues are one question seen from three angles. GROUPING is vocabulary's job and is order-independent: a particle joins forward through consecutive particles and stops at the first non-particle, and no name_order moves that stopping point. ASSIGNMENT is name_order's job: groups take roles by the declared order. The bugs existed because the implementation runs the two in the opposite dependency — `assign` hands out positions from `_effective_order` and `post_rules` then inspects a fixed list of ROLES, so P1's fold sites and P2's chain had coverage that varied with the declared order by accident. Consequences, each recorded in its own right below: the fold takes only the particle's own group (#364); the middle position needs no third site once grouping is order-independent (#365); and #368 reverses.
- 2026-08-16 #364 — the fold takes the particle RUN and the ONE name word it attaches to, not every remaining word.
  "de Mesnil Jean" is family="de Mesnil" plus given="Juan". Run,
  not particle: "de la Vega" is two particles onto one word, and an earlier wording here said "the particle", which its own example contradicted (rule-vs-decision-record review). Nothing ever argued for
  "takes everything"; it was the shape of v1's
  handle_non_first_name_prefix, not a decision. Measured before deciding, over every name in the three differential corpora with NO string prefilter: exactly ONE family holds words beyond its particle's group — "de Mesnil Garcia".
  #364's own body warns the change "breaks the v1 parity
  tools/differential protects" and that "each ledger would need re-examining"; measured, it is one name and one ledger entry. A shape filter (no comma, leading never-given particle, three or more words) returns THREE candidates, of which two do not move:
  "de la Vega" is one group start to finish, and "de Mesnil Jr."
  has only two name words because Jr. is a suffix. The pre-merge fact-check caught this stated as "the filter gives one name", which it does not. Measurement trap, recorded because the fact-check fell into it twice: the particle group runs through ANY particle, not only the never-given ones — "de la Vega" chains never-given "de" through AMBIGUOUS "la" onto "Vega". A detector that walks only the never-given run splits the group after "de la" and reports 50 false movers.
- 2026-08-16 #368 REVERSED — shipped behavior is correct.
  "Juan de la Vega" under FAMILY_FIRST groups [Juan][de la Vega]
  and assigns family="Juan", given="de la Vega". The earlier decision ("the particle wins ... whatever order was declared") was made before the grouping/assignment split was stated and cannot survive it: a mid-name chain HAS a head word and is positioned like any other group. What NON_GIVEN_NAME_PARTICLES guarantees is that the bare word never reads as a given name, not that no name part may begin with one. The asymmetry with the leading case is P4's, not an exception invented here: a leading particle chains nothing, so without the fold pure position makes the BARE particle the given name. That is measurable today on the ambiguous half, where no fold fires —
  "van Mesnil Juan" gives given="van", middle="Mesnil",
  family="Juan". given="de" is the reading the vocabulary exists to forbid, and the fold is what prevents it. The all-orders agreement in P1 is deliberate and is W4's shape: a wholly-hangul name reads family="김" under every declared order because the script carries a signal the order does not override, and a leading never-given particle is the Latin-script analogue. decisions.md#O4 already draws the line — "Words no vocabulary has claimed read by position" — so name_order governs the unclaimed remainder, which is most inputs.

- 2026-08-17 — SUPERSEDES the order-independence half of the 2026-08-16 order-precedence keystone entry above (#364, #365, #368). That entry says "no name_order moves that stopping point"; it may. Declaring FAMILY_FIRST or FAMILY_FIRST_GIVEN_LAST is precisely an assertion that what follows the family is NOT more surname, and a particle run's stopping point is exactly the question of where surname material ends — so the declaration is evidence about it, not merely about which slot the result lands in. The grouping/assignment split survives everywhere else; what is narrowed is the claim that the stopping point is one of the things order cannot touch.
  Concretely: under GIVEN_FIRST a leading particle run keeps joining to the end of the name. (This entry originally said that makes "de Mesnil Jean" read "exactly like pennie von bergen wessels — the same shape". Measured while implementing #395, that is false and is corrected here rather than left standing: pennie is the GIVEN name there, von is ambiguous so this fold can never fire on it, and the surname reading comes from P2's mid-name chain. What the real name does support is the weaker claim the argument needs — that a particle followed by several words can be all surname, "von Bergen Wessels" being one.) Under a family-first order the run stops after its first non-particle name word, because the caller has said the rest is given or middle.
  Accepted, and it is the cost that decided #364 three separate ways before this: "de la Vega Juan" under the DEFAULT order stays wholly a surname. A caller who means family "de la Vega" plus given "Juan" writes the comma. The mixed-language shapes are what make the greedy reading look wrong, and the parser cannot see language.
  Why the question kept thrashing, worth recording so a fourth attempt does not start from scratch: "how much does a leading particle run take" is a LANGUAGE judgement being forced through a POSITION heuristic, in a parser that has correctly refused to detect language (decisions.md#O4, rules.md's Not-in-scope). Every argument in the thread — `de`/`do` as Vietnamese surnames, `von` as German, `dos` as Portuguese — is really about which tradition the name belongs to. The order declaration is the one place a CALLER supplies that information instead of the parser guessing, which is why keying on it is better than any position rule we tried.
  Implementation consequence: the stopping point may now read name_order, which the superseded sentence forbade. Whether that lives in grouping (order-aware) or as a split in assignment is open — PR #391 showed assignment cannot split an existing piece today, so choosing assignment means giving it token-level slicing.
  Supersedes nothing else: #368's reversal, the trailing-orphan rule P6, and the claim that a leading never-given particle takes the FAMILY rather than a given name all stand.
- 2026-08-18 #395 — WHERE THE STOP LIVES, settling what the entry above left open. Not grouping and not assignment: the fold in post_rules, which already retags tokens and is the only site that can express the stop without moving a piece boundary. Grouping was PR #394 (four regressions) and assignment was PR #391, where the finding was that assign cannot split an existing piece — and the piece is the problem: "de la Cruz Juan Carlos" groups as [de][la Cruz Juan Carlos], the ambiguous particle having chained forward, so any rule that stops "after the first name word" must cut INSIDE a piece. post_rules can, because roles are per token and nothing downstream reads pieces (measured: only _assign, which runs before it, and post_rules itself). Grouping therefore stays order-independent, which keeps the 2026-08-16 keystone intact everywhere except the reach of this one fold.
  The order is READ, not re-derived: assign now records the order it actually used on ParseState.order, and the fold keys on that. policy.name_order would have been wrong — a script_orders entry can put the family first under a given-first policy, and the roles assign already wrote would then disagree with the roles the fold computes. The same reasoning already appears one function away, where the PARTICLE_OR_GIVEN emitter reads the role off the token rather than assuming given.
  A unit, not a token: a particle chain (P2), a conjunction join (P3) and a bound given-name pair (P5) each count once. All three are read off the TAGS rather than the pieces, but for two different reasons, and the difference matters to anyone who tries to simplify this. The conjunction join grouping DID build and the prefix chain then swallowed: the name reaches the fold as [de][la Vega y Santos Juan], the ambiguous particle having chained forward over the join, so the JOIN's boundary is gone. The leading particle keeps its own piece, and must — the fold's site test wants a lone piece, so a one-piece name would not fold at all. The bound-given join grouping never built at all: P5 joins only where the bound word is the first non-title piece, and at a fold site the first piece is the particle, so "ibn Awf abdul Rahman" arrives as four separate pieces. Restoring piece boundaries in group would fix the first and silently split the second. It also makes P5's new implemented: pointer stronger than a citation — this is the only place that join happens in this shape. This is what keeps "de la Vega y Santos Juan" from stopping between Vega and Santos — the case rules.md#P3 was amended for on 2026-08-17, now executable.
  Measured, with the denominator this log requires: of 751 corpus names, SEVEN reach P1's leading fold (DE MESNIL, De Groot, de Mesnil, de Mesnil Garcia, de Mesnil Jr., de la Vega, dos Santos) and exactly ONE of those has anything past the run to lose, so one moves (de Mesnil Garcia) under each family-first order and none under the default. The single mover is a fact about the corpus holding no family-first listing with leftovers, not about the blast radius. The differential harness cannot see any of it — it runs the corpus under the default policy against 1.4.0, which has no name_order — so its exit 0 is evidence for the accepted cost and for nothing else. The verification that counts is the two-leftover shape: mutation-checked, discarding name_order from the leftover placement fails three tests across three layers — the FAMILY_FIRST_GIVEN_LAST case row (one of the two, the FAMILY_FIRST row being blind to it by construction), test_post_rules.py's test_the_two_family_first_orders_differ_at_two_leftovers, and rules.md's P1 example for that order. An earlier draft of this sentence said "exactly one, and no other test in the suite": true when measured, false one commit later, because two of the three guards were added after the measurement and it was not re-run. Recording the failure mode as well as the number, since it is this log's recurring one.

Declined:

- A strict xfail asserting "de Mesnil" → family under FAMILY_FIRST (#359 review) — #359 deliberately left those semantics open, and a strict xfail decides the question by the back door.
- Keying the leading-particle exception on "the first piece that is not a title" — the obvious implementation, and wrong: st, do and freiherr are titles AND ambiguous particles, so it collapsed
  "St John Smith" into one given name and broke test_add_title
  (which adds "te", also a particle). The shipped predicate is
  "not a title or a prefix".
- 2026-08-16 — deleting P4 so a leading particle chains and is then positioned, which is the only way to make "de Mesnil Jean" vary by declared order. It avoids given="de" (the group would be
  [de Mesnil]) but breaks "de la Vega": measured, a single group is
  positioned by the declared order — "Cher" reads given under GIVEN_FIRST — so "de la Vega" would read given="de la Vega" unless a further rule forced a particle-headed group into the family. Add that rule and [de Mesnil][Juan] yields the #364 reading anyway, so the deletion buys nothing and costs P4.

Open: [#360](https://github.com/derek73/python-nameparser/issues/360) which particles count as never-given (the criterion is settled at decisions.md#vocabulary-collisions; the 39-member application is not).

### P2 — particles join forward

- 2026-08 #367 — REMOVED a chain, it did not create one: before, the title displaced the particle out of the leading position, so
  "Sir de Mesnil" grouped [Sir][de Mesnil] — a chain — and
  reported given="de Mesnil" with no family at all. After, "de" is the leading name piece, and a leading particle chains nothing (rule P4): the family reading comes from P1's fold, which is why P1's fold needed no change (its interacts: points here). Grouped today: [Sir] [de] [Mesnil].

- Group distribution is load-bearing, not incidental: the v1 Portuguese tests (test_portuguese_dos, test_portuguese_prefixes) require multiple particle groups to land across middle and family, while #132 wanted the combined double-surname reading — the same shape with opposing wants, which is why the combined reading lives in the surnames VIEW and the split in the fields.

- 2026-08-22 #424 — the chain stops where assign's trailing peel begins. The chain ran "until a trailing suffix begins" and asked with the suffix-piece test, which vetoes a bare `V` as an initial — the #401 question at a third site — so `John van der Berg V` read family 'van der Berg V' (1.4.0 read it so too: shipped since 1.x), and `John van der Berg Ma` read family 'van der Berg Ma' where 1.4.0 read suffix 'Ma' — a 2.0 regression on S2's other fork. Both now stop where `_trailing_start` says the run begins: the S2 peel (`_peel_trailing` over `_peel_walk`, shared with assign and P5's reserve since #425) read over the pieces as they stand from the first name piece, once per segment, kept as a length from the end that the chain's merges ahead of it do not move (the benchmark's `particles` shape is the guard). The chain takes both forks — its merges leave the acronym at least the three pieces the fork counted, so assign peels it after as the walk did before — where the maiden walk takes only the numeral (the M2 entry says why). The fork reads the piece before the numeral as it stands, so `John van der J. V` keeps family 'van der J. V', as `John J. V` reads the V as a name; and a numeral with a suffix behind it is not last in the walk, so `John van der Berg V Jr` keeps family 'van der Berg V'. No differential corpus name had either shape; the rules examples carry them — the numeral against every baseline, the acronym against 2.0.0 and 2.1.0. Two more sites were asking assign's question with a test of their own, both found by the reviews of this change. The walk's starting point and P4's leading-particle scan asked group's `title()`, which does not see H2's unlisted abbreviations — assign peels those as titles all the same — so `Xyz. van Johnson` chained where `Dr. van Johnson` did not (#367 had keyed the exception on the first piece of the name, by group's test), and `Xyz. van Berg MA` read family 'van Berg MA' on every 2.x tree — the chain had taken the acronym, as it took every acronym; the draft that stopped before it left assign two pieces where the fork had counted three, and MA became the family. Both ask assign's own test now — `_is_leading_title` and the period-abbreviation pattern moved down into group, with `_leading_titles` as the one definition of where the name begins: the chain's walk, the bound join's first name piece (the second docs review found that one still on group's test — `Xyz. abdul John Smith` joined nothing where `Dr. abdul John Smith` read given 'abdul John') and, through `_is_leading_title`, the scan and the PARTICLE_OR_GIVEN report — so `Xyz. van …` reads as `Dr. van …` does. `Esq. van Gogh`, a corpus name, moves with it (1.4.0 read family 'van Gogh' for both, as #367's rule records; its own rule at every baseline, and one for the P4 example). A particle of the unambiguous suffix vocabulary too (vd, mc) is a suffix piece to the peel: where it opens the trailing run the chain stops before it as before any suffix word and the peel takes it — `John Smith Mc V` reads suffix 'Mc, V' where master and 1.4.0 read family 'Mc V', a class the second docs review found unrecorded (2,554 of its 117,306 constructed names, an eighteen-word pool that puts the words in every position) — and where it continues a prefix run, the run takes it as a particle, so `John van Mc` keeps family 'van Mc', every baseline's reading and the one P6 chose for the shape after a comma. Both are Accepted under P2. The fork's count was the last thing the chain asked on its own authority, and the reviews found it wrong behind a title-and-particle word: #367 stops the leading-particle scan at `Freiherr`, `St`, `Do` — the name's own first piece — where assign's title peel steps over them, so the chain takes the name's first word, and the acronym the fork counted with three pieces met assign with two: `Freiherr von Berg MA` read given 'von Berg', family 'MA' (1.4.0's reading, as it happens; master read family 'von Berg MA'). So the chain asks the peel again over the pieces it leaves — `chain(tail)`, then `_trailing_start` over what it built, and a second run without the stop where the verdict changed, the snapshot being one copy per segment with a trailing run — and takes what assign will not peel: family 'von Berg MA'. The numeral cannot flip, a chain group never being initial-shaped, so `Freiherr von Richthofen V` keeps suffix 'V', and its chain, the one name piece left, reads as `Dr. Smith V` reads — where 1.4.0 and master read family 'von Richthofen V'; the code review asked for that boundary to be on record, and P2 carries it. #410 (2026-08-25) then moved the name half of both: a title with one name word behind it names the family whatever stands beside it, so the pair reads family 'von Richthofen' and family 'Smith' today, where each read `given` with no family when this entry was written. The boundary is unchanged — the chain still stops before the numeral, and the two names still read alike; only the field the one name piece lands in moved. This name is the fifth corpus name #410 moves, and the only one already classified, its ledger rule's `fields` spanning the narrower diff.

### P6 — the trailing orphan particle

- 2026-08-23 — `Jong, vd` is C1's now, not P6's: C1's listing-form clause (#296's bundle) reads a post-comma part that is nothing but suffix words as the credential run, and `vd` is suffix vocabulary, so the row reads family 'Jong', suffix 'vd' by statement (it read so before, by the lone-piece deviation). The #380 scope note below calling it deliberately unresolved is superseded for that one row; the P6 question — a tussenvoegsel with no given word left — is unchanged.
- 2026-08-18 #379/#380 IMPLEMENTED — the rule shipped as stated, both `deviates:` markers out. Keyed on the token's VOCABULARY rather than its assigned role, which is what gives the S2 precedence its effect: assign reads a trailing `vd`/`mc`/`do` as a post-nominal, and the attachment overrides that after a comma. The words-to-spare guard is a PIECE test, not a count — every trailing piece that is wholly particles attaches, and the run must leave a GIVEN word ahead of it, so "Nguyen, Van" keeps its only given word. Not "a name word": see the defect below, where that wider reading passed on family text P1 had just produced. MIDDLE was in the test briefly and came out — 0 hits over 740,552 instrumented guard sites, and structurally unreachable, since the only rule that leaves a MIDDLE with no GIVEN ahead of it is gated on state.order, which assign never records on the family-comma path P6 runs on.
  Measured: ONE of 751 corpus names moves ("Vega, Juan de la"), at all three baselines, and it needed a new ledger rule in each. The LEDGER rule's regex — not the parse rule, which has no regex and fires on the whole particle vocabulary through the tag — is a PARTIAL copy of PARTICLES, deliberately: the words that actually end a Dutch, Iberian or German listing, not all 70. The ledger guards forced that narrowing: the first draft used the neighbours' bare comma regex, which claimed 215 names and reached two protected exclusions. A member `des` had to come out as well, and for a reason worth keeping straight: it is not in PARTICLES at all, so it could copy nothing, and the text it reached was the opening of "Designated Judge David A. Ezra" rather than a person's name. `_reaches_non_vocabulary("des", PARTICLES)` prints the FRAGMENT it matched, `['Des']`, not the name containing it — which is how the retracted wording arose in the first place.
  Two defects found in review, both from the rule's neighbours rather than from its own logic. P1's fold runs EARLIER in the same function and retags all of segment 1 to FAMILY, so a words-to-spare test written as "some name role remains" passed on family text P1 had just produced — and the attachment then hoisted the particle in front of a base it never preceded ("Smith, de Mesnil van" → 'van Smith de Mesnil'). 1.4 gives 'de Mesnil van Smith' for that input and 2.1 gives 'Smith de Mesnil van', so neither the defect nor the fix restores 1.4 — what the fix restores is 2.1, the pre-comma-family reading having moved back in 2.0. The test has to name GIVEN, which is what the rule says. And a post-nominal sits BEHIND the tussenvoegsel in this listing, so the backward walk stopped on it and the attachment never fired: "Berg, Jan van Jr." and "Berg, Jan van, Jr." parsed two ways on nothing but a comma. The walk now steps over a trailing piece holding no name, unless it is itself particle vocabulary — `vd` arrives suffix-roled and IS the run.
  Fallout worth its own record: "Vega, Juan de la" was the input tests/test_initials.py used to build an ALL-PARTICLE middle name, and this rule takes that shape away from THAT input. It does not remove the shape — "Vega, Santa de la", "Berg, Van der" and "Nguyen, Van Le" all still produce one in the default order, because the trailing run swallows the given word too and the attachment then declines. The test is repointed at the last of those rather than deleted. Whether such a middle should exist at all is #402 — for a never-given particle it is #365's defect, and for an ambiguous one the parser reports it as a middle name and then declines to initial it, which is a miscount either way.

- 2026-08-16 (order-precedence keystone; #379, #380, #365) — a particle ending the name has nothing to link forward to, and no particle is a name by itself, so it attaches to the family name standing beside it and renders BEFORE it. The distinction from a chain is what makes this a rule rather than an exception: a chained group has a head word and can be positioned; an orphan has no head, so position has nothing to work with.
- Scope: the COMMA form only, deliberately. "Jong, Anke de" is unambiguous — the comma has already named the family. Without the comma the written shape is not settled: "Jong Anke de" may be a misformatted listing (arguably a missing comma under a declared family-first order) and "Jong de" may be a given name beside a particle. Those keep their positional reading and are not tracked as deviations.
- The words-to-spare guard is load-bearing, not incidental. #379's own subject is "van", which is in the AMBIGUOUS half — so a rule keyed to never-given particles alone would not fix the issue it was filed for, while a rule with no guard breaks Vietnamese
  "Nguyen, Van" (given="Van") by eating the only given word. The
  guard is S2's shape, reused: consume only when the name has words to spare.
- Rendering before the family has precedent in rules.md#R1 — folded family words under O3 already "render before the rest of the family wherever they stood in the string". The surnames view renders backwards today ("Vega de la", "Jong de") and flips with this rule.
- Measured under this rule's OWN comma scope, over 782 corpus names (245 of them comma-bearing): exactly one moves,
  "Vega, Juan de la" → family="de la Vega". A pre-merge fact-check
  corrected an earlier count of three here — "Smith van der" and
  "Sander van" have no comma, so the rule as scoped does not reach
  them; they were measured before the comma scoping was chosen and carried forward unfiltered.
- What that number means is the opposite of reassuring. The corpus holds 245 comma names, TWO of which end in a particle and one of which clears the words-to-spare guard ("Nguyen, Van" is the other, and the guard is what keeps it a given name), so it is very nearly blind to the shape this rule governs — Dutch and Flemish listings are barely represented. Treat the one as evidence about the corpus, not about the blast radius, and run tools/differential at all three baselines in the implementing PR regardless of how small this looks.
- 2026-08-16 (pre-merge coherence pass) — P6 and S2 CONTEST
  "Berg, Jan vd": today S2 wins and reports suffix="vd", while
  P6's marker asserts family="vd Berg". P6 wins, and the rule says so in its statement rather than leaving the pair to file order. The reason is C-ii's, not a new judgement: vd as the British Volunteer Decoration is rarer than vd as van der, and a trailing abbreviation AFTER A FAMILY COMMA is the tussenvoegsel position specifically. Scope check on the precedence, so it cannot creep: it reaches only words that are both particle and suffix vocabulary, in the trailing-orphan position, under a family comma, with a given word to spare. "John Smith, PhD" and "Smith, Jr." are untouched (not particles), and "Jong, vd" is untouched (no given word remains), which is why that row stays out of scope rather than becoming a counter-example. Recorded because the pair was declared in `interacts:` and left unresolved — `interacts:` is advisory by design and pins nothing, so a declared interaction is a prompt to state the outcome, not a substitute for stating it.
- 2026-08-16 — P6 is the first rule in rules.md that nothing implements. Legitimate per the preamble (the document is normative, and a gap is a tracked deviation), but it left the rule pointing at nothing, so the shape got a pointer rather than an exception: `tracked: #379, #380` in place of `implemented:`, with exactly one of the two required of every rule. An unimplemented rule can no longer sit here untracked, and a shipped rule cannot keep a stale tracking pointer after its issues close. Mutation-tested three ways before being believed (drop the pointer, carry both, malformed refs); each fails.
- The RATIONALE first shipped here was wrong and is corrected: it read "no particle is a name by itself", which is true only of the never-given half — decisions.md#vocabulary-collisions says the opposite in as many words ("most particles are short words that double as names"). The error mattered rather than merely reading badly: the words-to-spare guard exists BECAUSE the rule reaches ambiguous particles (#379's own subject is "van"), so the rationale undercut its own guard. Caught in review, after a coherence pass that interrogated rule-vs-rule pairs and never checked rule-vs-decision-record — which is the gap to close next time, not a one-off.

Open: [#380](https://github.com/derek73/python-nameparser/issues/380) covers "Berg, Jan vd" under this rule, but the vd reading itself is decisions.md#vocabulary-collisions (C-ii); and the no-given-word case "Jong, vd" is deliberately unresolved — see the scope note.

- 2026-08-22 (late review of #425's docs, PR #426) — the bound join runs before P6 sees the name, and P6 now says so. P5's new title-word clause took `do`, `freiherr` and `st` — title AND particle vocabulary, until #296's audit took `do` out of TITLES, since when the join takes `do` as the particle it is — away from the attachment: `Berg, abdul do` had read family 'do Berg' since #379 and reads given 'abdul do' now. That is 1.4.0's reading, and it is what every other particle already did: `Berg, abdul van` read given 'abdul van' at 1.4.0, on 2.0 and 2.1, and on master before #426 — the old reserve's title exclusion had held only the title-word trio back. The precedence is the stage order (P5 joins in group; P6 attaches in post_rules, over what is left) rather than a judgement about tussenvoegsels behind bound words, and both rules carry it as Accepted with `Berg, abdul van` as the example, parity at every baseline. The precedence has one exception, found by the review of #424's docs when the Accepted clause claimed the attachment "never sees" the particle: a trailing particle of the unambiguous suffix vocabulary too — `vd`, `mc`; `do` is ambiguous, and a name word to the join — is a suffix piece to the join, which never takes one (#421), so the attachment does see it, and `Berg, abdul vd` reads family 'vd Berg' where 1.4.0 read given 'abdul vd'. Both rules say so, with the example; at 1.4.0 the fix(comma-precomma-family) rule already reaches it (a rule written for it was shadowed, and dropped).

- 2026-08-29 #405 (the A1 deviation, reversed) — the attachment reports the fork it decides, and the Accepted clause saying it does not is deleted rather than re-tracked. What that clause rested on was assign's own scoping comment: after a comma that fixed the family, a leading given-position particle is "not meaningfully ambiguous". True of the FAMILY — the comma names which piece that is — and not of the particle standing behind the given name, which P6 decides on its own a stage later, out of the same vocabulary that makes `Van Johnson` report `particle-or-given` with no comma in sight. The old state was the shape A1 forbids: `Van Johnson` reported the fork, `Nguyen, Thi Van` decided the identical fork silently and lost a given name doing it.
  TWO kinds, keyed on WHAT THE ATTACHMENT OVERRODE and not on what the words are, because `mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE` asks the emitted fork to be the one taken. Where assign had read the run as a post-nominal, the overridden reading is the post-nominal — `suffix-or-name`, which is exactly the S2 precedence this rule already claims in writing. Otherwise, where the run holds an ambiguous particle (`van`, `Van`, `la`), the overridden reading is that word as a name word — `particle-or-given`. One kind for both would assert "particle or given" about a word the parse had read as a credential and never weighed as a name, naming a branch nobody considered.
  The key is the READING and not the vocabulary, and the docs review caught a first draft that said otherwise ("a word that is also suffix vocabulary declines a post-nominal"). That wording is wrong for both of the words it most obviously describes. `do` is the third member of the particle ∩ suffix-acronym overlap and reports `particle-or-given`: it sits in the AMBIGUOUS acronym half, which had already left it a name word, so no credential reading was ever taken for the attachment to override. And a run of several words is read whole — `Berg, Jan de vd` reports NOTHING though `vd` is suffix vocabulary, because behind `de` the run was read as name words, where the lone `Berg, Jan vd` reports `suffix-or-name`. Both are pinned in tests/v2/cases.py; neither is in any corpus, so nothing else would have caught the wrong statement.
  The two arms are ORDERED rather than asserted disjoint, and that is a correction to the plan, which recorded them as disjoint by construction ("a suffix-vocabulary particle arrives suffix-roled and IS the whole run"). That premise is a property of the SHIPPED vocabulary, not of the rule: `vd` and `mc` are the only words in both the particle and the unambiguous suffix sets and neither is ambiguous particle vocabulary, so no shipped input reaches both arms — measured, 0 both-arm runs over 29,400 generated names (every ordered pair of the 70-word particle vocabulary in six family-comma shapes: bare, behind a given word, behind a title, before `Jr.`, before `MD`, and with the pair split across the comma). But a caller's `Lexicon` may put one word in both, and the `assert` the plan asked for DID fire on `Berg, Jan zz` under a Lexicon adding `zz` to `particles_ambiguous` and `suffix_acronyms`. `rules.md#A1` says parsing never fails on any input and names exactly one exception, a caller's segmenter; an `AssertionError` out of a stage would be a second. So the code decides instead: the suffix arm wins, because assign had read the word as a post-nominal and the name-word reading was never on the table for the attachment to decline. Pinned in tests/v2/pipeline/test_post_rules.py.
  The never-given half stays SILENT, and that is the substantive half of the decision. `Jong, Piet de` has no live alternative: `de` is not a name in any reading and is not suffix vocabulary either, so the attachment chose between one reading and nothing, which is not a fork. Reporting it would be "the code got here" keying, the failure this project already has on record (a fork reported for every ambiguous particle at once on `Dr. Van Jr.` — the count is left out on purpose, since decisions.md#vocabulary-collisions says to recompute before quoting it).
  Measured over the 1094-name differential corpus: P6's attachment fires on 9 names, of which 5 take the ambiguous arm (`Beethoven, Ludwig van`, `Berg, Jan van der`, `Nguyen, Thi Van`, `Vega, Juan de la`, `der, y van`), 2 the suffix arm (`Berg, Jan vd`, `Berg, abdul vd`) and 2 stay silent (`Jong, Anke de`, `Jong, Piet de`). Seven names gain an ambiguity kind and no role field moves that was not already moving. Read that seven as evidence about the corpus rather than about the blast radius, the caution the 2026-08-16 entry above already gives for this rule: 304 of the 1094 names carry a comma and only 13 end in a particle, 11 of those being rows the #379/#380 arc added itself (this change added four corpus names and not one of them carries a comma, so the 11 is unchanged by it). The reach is a SHAPE — every family-comma listing ending in an ambiguous particle, which is to say ordinary Dutch, Flemish and unaccented Vietnamese listings — and a caller holding such records sees a report on all of them, not on seven. The LEDGER shape that follows from that is not the one the plan predicted, and the difference is worth recording because it will recur for any report added to a rule whose fields already move. A new `fields = ["_ambiguities"]` rule explains none of the seven: `classify` matches on a rule's `fields` being a SUPERSET of the whole diff, and against 2.0.0 and 2.1.0 these names diff in `middle`/`family` (or `family`/`suffix`) from #379 and #380 as well. So the repair is to WIDEN those two existing rules to `{middle, family, _ambiguities}` and `{family, suffix, _ambiguities}` in both ledgers, not to add a rule. Each widened rule still passes the #452 equality check, since the union over the names it explains is exactly what it declares — the two silent `Jong` names contribute `{middle, family}` and the five ambiguous ones contribute `_ambiguities` on top. 1.4.0 needs nothing at all, having no ambiguity surface to diff. Gate before and after: byte-identical output at all three baselines apart from the provenance header — 1094 names, 229/194/102 intentional, 0 unexplained, and no per-heading count moved. The seven names were already in the diff; only their field sets grew.
  The most visible consequence, and it is not the Vietnamese one: `Beethoven, Ludwig van` — the textbook-correct Dutch listing, read exactly right — now carries a report. That is honest rather than regrettable. The parser cannot separate it from `Nguyen, Thi Van`; the two are the same string shape over the same vocabulary, and a report on one is a report on the other. A caller who wants only the doubtful cases has nothing here to filter on, because there is nothing to filter on in the input either.

- 2026-08-30 #467 (the no-comma site, and the given position given back) — a declared family-first order names the family as a comma does, so P6's attachment fires without a comma too. It is keyed on TWO things, and review showed the second is not implied by the first: the run must stand in a MIDDLE — the one position that means nothing for a particle, middles being further given names — and it must END the name. Together they name the order without asking it: the default order ends with the FAMILY and FAMILY_FIRST_GIVEN_LAST with the GIVEN name, so only FAMILY_FIRST can put a name's last word in a middle. This DECIDES the parenthesis the 2026-08-16 Scope bullet left open ("arguably a missing comma under a declared family-first order") and narrows that bullet to the default order.
  P1 LOSES ITS GIVEN-POSITION SITE in the same change, and that half is a correction rather than a narrowing. That slot holds what the caller DECLARED to be the given name; the never-given vocabulary supplies a reading where position leaves the question open and vetoes none that position has already given. `Ménil de` under either family-first order now reports given `de`.
  THE PREMISE THIS REPLACES WAS FALSE, and it had been stated in three places (rules.md#P6's rationale, `_lexicon.py`, `config/particles.py`): that a never-given particle "cannot be a name at all". Measured, all 33 members report given == themselves when parsed bare. This is the SECOND correction of the same over-claim in this entry — the 2026-08-16 note above records dropping "no particle is a name by itself" for the identical reason. The conclusions drawn from it were mostly right; the reason was not, and it is load-bearing, because stating it as a property of the VOCABULARY invites the same inference for a particle ending the name, where there is nothing to join forward to and the writing is all there is.
  NO VOCABULARY TEST AT THE NEW SITE, and that is the substance of the design. The comma path needs one because a comma cannot separate the Dutch reading from the Vietnamese; the declared order can, and does both at once — `Beethoven Ludwig van` under FAMILY_FIRST gives family 'van Beethoven' though `van` is ambiguous vocabulary, and `Nguyen Thi Van` under FAMILY_FIRST_GIVEN_LAST keeps given 'Van' though it is the same word in the same set. A never-given test here would have excluded 37 ambiguous particles — von, di, da, del, le, and `van` itself, the flagship word of the listing the rule is named for. An earlier attempt (#466) carried exactly that test and failed on the Dutch case because of it.
  NO RE-LAYOUT EITHER, and this is a property of ONE order rather than a simplification: under FAMILY_FIRST the roles run family, given, middle, middle…, so dropping a trailing MIDDLE leaves every other piece's role untouched. It is false of FAMILY_FIRST_GIVEN_LAST (family, middle…, given) and of the default order, which is the second thing the ends-the-name test secures. #466 removed a piece and re-laid the leftover out, which lost a given name outright on `van Berg Jan de` and promoted a post-nominal into the given slot on `Berg Jan Jr. de`. Both defects are structurally unreachable here. The one guard that remains is the base: attaching to an all-particle family renders one particle in front of another, and R2 reads those words as ordinary name words, which no rule reorders.
  THE INVARIANT THIS RESTS ON, and the reason to trust it over the argument it replaced: for a name of the form `Family Given [Particle]`, the family-comma writing under the default order and the comma-less writing under FAMILY_FIRST parse identically. Measured over ALL 70 particles × 3 families × 3 givens = 630 names: 0 of 630 agreed before this change, **603 of 630** after. The invariant draws the never-given/ambiguous line by itself instead of having it asserted, which is why no vocabulary test appears at the site.
  The 27 failures are the three words that are BOTH particle and suffix vocabulary — `vd`, `mc`, `do` — and they are a real limit, not noise: P6's precedence over S2 is stated inside the comma paragraph and justified by the comma ("a trailing abbreviation after a family comma is the tussenvoegsel far more often"), so the comma form gives family 'vd Berg' where the comma-less family-first form gives suffix 'vd'. The rule now says so where it states the correspondence.
  A FIRST DRAFT OF THIS ENTRY CLAIMED 216 of 216, and the number came from a script that sliced the particle vocabulary — `sorted(particles)[:14]` — which excluded exactly those three words. That is AGENTS.md's axis 2 ("your detector is a second, unreviewed implementation") in its purest form: the detector agreed with the design because it inherited the design's blind spot. Recompute over the WHOLE vocabulary, never a sample.
  Scope the invariant to that SHAPE as well: 219 of the 304 comma-bearing corpus names parse differently with the comma simply removed, because a comma also delimits titles and suffixes. (An earlier draft said "181 of 247", which reproduces under no definition of "comma name" — the 304 matches the count the 2026-08-29 entry above already records.)
  An asymmetry falls out of it rather than being carved: shape 2 places the given name BEFORE the middles, so it corresponds to FAMILY_FIRST only. There is no comma format that puts the given name last, so FAMILY_FIRST_GIVEN_LAST has no twin to inherit an equivalence from. That is why the trailing particle is one order's business (#469).
  ONE EXAMPLE LINE WAS DELETED RATHER THAN UPDATED: `"Nguyen Thi Van" family-first → middle="Van"`. The name is written in FAMILY_FIRST_GIVEN_LAST; its FAMILY_FIRST reading is wrong by construction, so the line pinned a value nobody wants and nothing should depend on. Recorded here because the general question — whether a rules.md example should declare its format, and whether the doc test should refuse to run one outside it — is #470 and is NOT decided by this change.
  REACH: 30 of 6606 parses move — this tree's 1101-name corpus × three `name_order` values × `middle_as_family` off and on — over twelve names, NONE under the default order. Recompute by parsing the four `tools/differential/corpus*.jsonl` files of this tree, deduped, under each of the six policies, and comparing the seven role fields against a checkout of the parent commit reading the SAME corpus files; the comparator is the pre-change code, never a construction of this rule. Dated snapshot, measured 2026-08-30. Note the corpus grew from master's 1094 by this change's own example lines, which is why the count must name the tree it was taken on.
  The differential gate cannot see any of it: `compare.py` parses every name as `HumanName(name)` and sweeps no policy, so no non-default `name_order` behavior has ever been compared across versions. Output is byte-identical at all three baselines apart from the added corpus names, none of which diffs.
  WHAT REVIEW FOUND, because the corpus could not. Four agents on PR #472 found the site firing outside the order it was reasoned from, in two directions, and the differential corpus reported neither: 52 default-order names (a conjunction stops a particle's forward chain and leaves it standing in a middle — `Maria Luisa y de la Cruz` gave family 'de la Cruz' where master gives 'la Cruz') and 366 FAMILY_FIRST_GIVEN_LAST folds of a particle that does not end the name (`de Anke van y` gave family 'van de Anke' with the given name still behind it). Of 1099 corpus names only 6 hold a conjunction-then-particle adjacency and none in this shape, so the REACH recipe honestly reported 0 default-order movers throughout. The count was right and the population was blind; "none under the default order" was a property of the corpus, not of the code. Both are now `· boundary` examples in rules.md so the doc runner holds them.
  The repair is one clause — the run must END the name — and it subsumes the order test a first repair added: measured over 542,592 generated parses, an explicit `state.order[0] is Role.FAMILY` never decides anything the ends-the-name test has not already decided, so it is not in the code. What IS kept is `state.order is not None`, which is what keeps this site off the family-comma path (0 co-firings with P6's comma site over ~892,000 parses).
  A stale index list was repaired with it: O1 and O2 retag between roles without recomputing, and this site was the first consumer of `families` after them (33,840 stale observations instrumented). No shipped input reaches a wrong answer through it — no `_TURKIC`/`_EAST_SLAVIC`-matching word is particle vocabulary — so the fix is unwitnessed by any test and is recorded here instead.
  Open: [#471](https://github.com/derek73/python-nameparser/issues/471) is the leading fold's REACH under the default order (`de Mesnil Jean` reports the whole string as the family). Split out of this change deliberately: it is a 1.4.0 parity break on the order every existing caller is on — prototyped, 5 unexplained diffs at that baseline and a failing v1 parity test — where nothing under a family-first order has a v1 answer at all.


### M2 — the maiden-marker rule

- 2026-07-03 (maiden-bucket design; #274 filed 2026-07-07, landed in the v2 core, PR #288) — the marker takes everything after it up to a trailing suffix, greedily: "née Jones Smith" is a two-word maiden name, matching how the marker is actually used in running text. The marker itself is dropped as structural, like a delimiter character.

- 2026-08-19 #399 (PR #409) — a maiden marker bounds a particle join reaching it from the left, where the marker is actually taken as a marker. rules.md carried the opposite as an Accepted limitation ("a particle chain swallows a marker in its path, the join (P2) running first"), but that was a consequence of stage order rather than a judgment: the chain merges pieces before the marker is consumed, and the consumer looks for a lone marker piece which by then no longer exists. So `Ursula von der Leyen geb. Albrecht` lost the maiden name that `Ursula Leyen geb. Albrecht` kept.

  The gate on the stop is the whole substance of the rule and was added in review, not in the first cut. Sharing one marker-piece predicate between the stop and the consumer settles what a marker piece IS; it does NOT settle whether the consumer takes, because the consumer also needs a non-suffix piece after the marker. Stopping without that left the marker as a piece of its own, and a lone trailing piece takes a role field: `Jane van der Berg née` read middle 'van der Berg', family 'née' — the marker became the surname and the real surname was demoted, which is the stranding the stop exists to prevent, one field over. Three consequences of the ungated form, each measured and each fixed by the gate: `Jane van der Nee` moved a Dutch bearer of the attested surname Nee out of the family (M1 already names Nee as a surname); `Ursula von der Leyen geb.`, an ordinary truncated record, reported the marker as the surname; and `St St née` stopped reporting its particle-or-given fork, because group's emitter is guarded on the chain having merged something and the stop made it merge nothing for a different reason than the guard assumes — an A1 violation of the shape #405 tracks, and worse than #405's, since it REMOVES a report callers already see. The gate tests the consumer's own condition rather than restating it, which is what makes the two halves agree. M2 states the gate ("a marker left as a word bounds nothing"); the trailing-marker rows pin it.

  Weighed against moving the maiden handler ahead of the chain, and the recorded reason for preferring the stop has been corrected twice. It is NOT that the reorder disturbs names whose maiden side carries its own particle chain: `Anna Müller geb. von der Berg` is identical before #399, after it, and under a reconstructed reorder, and mechanically cannot differ — no suffix and no conjunction on the maiden side means merged and unmerged pieces hand the consumer the same words. The reason is scope: the reorder changes what the consumer receives for every name rather than adding one condition, and two review sweeps found no test and no corpus name that separates the two options, so it would ship unmeasurable. A review sweep also found the reorder would reach maiden names the stop does not (the P3/P5 join-swallow below), and would drop P3's rootname count when the maiden words leave early. That second effect was recorded here as a COST and #411 has since shown it is the opposite: P3's count has the same defect P5's reserve had, counting words a later pass removes, so `juan y garcia nee jones` loses its family name entirely. Correcting the ledger rather than leaving it: the reorder's real price is scope — it changes what the consumer receives for every name — not that consequence. It stays available, and #418 tracks the P3 count on its own.

  Two things fell out rather than being decided separately: the same issue's open question about the leftover given slot (the marker used to compete for it, giving `de la Cruz née Vega` given "née" under a family-first order; consumed and dropped, it never reaches the placement — though a marker the consumer declines still does, which is M2's "just a word" reading and not a leak), and the wholly-particle family a stop can leave, which #404's R2 reading already covers — "Jane de la née Jones" reports family "de la" with a non-empty base. A 305,364-parse sweep at review found no empty-base or partition violation either side of the change.

  Two limits stay, now recorded in M2 rather than left implicit: the bound reaches only a marker standing as a word of its own, so P3's connective join and P5's bound-given join each still absorb a marker before the bound can see it — the deleted Accepted clause covered a class of which this fixed one instance and left two. (P5's was closed by #411 on 2026-08-21, and P3's the same day by the #412 / #417 / #418 entry below, which takes the reorder this entry set aside.) Tests carry the whole verification: of the marker-bearing differential corpus names none has a particle ahead of the marker, so the gate is blind to this class and its clean exit says nothing about the change. A synthetic cross-script row was written to reach the CJK corpus builder — the only route a case row has into any corpus — and then removed: it used that corpus's provenance as a back door, and it pinned nothing the Latin rows did not. The honest route is [#413](https://github.com/derek73/python-nameparser/issues/413), where the harvester's pattern turns out to miss every name written in backticks, which is how this tracker writes them. `Ursula von der Leyen geb. Albrecht` sat in #399's own title the entire time.

- 2026-08-21 #412 / #417 / #418 (PR #420) — the marker pass runs before every join in the grouping stage. The three issues were one shape: a join rule computed something — P3's word count (#418), the chain's stop condition (#417), the connective join's range (#412) — against a piece list the marker pass would rewrite a few lines later. #411 had fixed the same shape in P5's reserve by hand, excluding the departing words and declining a join onto the marker, and #418 argued the general fix was a guard at the merge primitive: no join may span a marker piece. Stated unconditionally that guard is the ungated stop the 2026-08-19 entry above measured and rejected (it strands `Jane van der Berg née`), so it would have to be gated on "a marker the consumer will take", evaluated live at every merge — #417's shared predicate, plus a per-merge scan the `"and " * n` benchmark shape would have to tolerate, plus the count exclusions kept in step. The reorder set aside above gets the property by construction: the consumer runs first, what it takes is gone before any count or join looks, and the stop, the exclusion and the shared-predicate question all disappear, a net deletion. P5's decline-onto-a-marker test stays, because it is about what a name word is, not about counting — measured: removing it re-read `Berg, abdul née PhD` as given 'abdul née'. It tests the LONE marker piece, which is the only marker the consumer can have declined on its own; a declined marker the particle chain has already taken travels with the chain, and P5 joins the chain — `Abd van der Berg née Jr Jones` reads given 'Abd van der Berg née' where the old stop had kept the marker out of it. Review found this; rules.md#P5 now says "standing as a word of its own" for that reason.

  The recorded ground for setting the reorder aside was that nothing separated it from the stop. That stopped being true when #413's harvest landed, two hours after #418 was filed: eleven corpus names flip P3's carve-out under an appended maiden clause (eight losing the family — #418 counted seven and six, before the harvest), and seven show the connective join swallowing the marker — `johnny y`, `Juan de y`, `e and e` with the marker behind the connective, and `Claus SchroederVP of`, `Jon Dough and`, `Jon Dough and of`, `jones, john e` with the marker ahead of it — where #412 recorded no corpus name behind it at all (review counted the seven; the first draft of this entry had three). Measured over every corpus name, every corpus name with ` nee jones` / ` née Jones` / ` née Jr Jones` appended (comma names included) and every case row, 4,180 inputs: 55 differ, three of them inputs this change itself adds (the P3 example, its appended forms, the new case row), and each of the rest either a shape of the three issues or the clause ceasing to change how the rest of the name reads — `The Hon nee jones` reads given 'The', family 'Hon' because `The Hon` does; `der, y van nee jones` reads family 'van der' because `der, y van` does. No ambiguity report moves. The suffix-comma tails, the ordering cost the #411 entry left unmeasured, were byte-identical in every shape tried — under the default policy, which review showed was the wrong population. With `extra_suffix_delimiters` configured, a delimiter core after a marker in a tail became a word the pass could take (`Smith, John, PhD née - Jones` read maiden '- Jones'), and a core the pass left standing alone stopped being dropped (`Smith, John, - née Jones` read suffix '-', maiden 'Jones'): the tail's core drop used to run before the pass and now ran after it. Closed before merge by having the pass read a tail segment with its cores screened out and leave them for the drop — the old order restored for cores — with both shapes pinned at the piece level. Two corpus names move at the 2.0.0 and 2.1.0 baselines, the #412 pair; at 1.4.0 fix(#274) already classifies them. A third, `Juan y Garcia née Jones`, is the example this change adds to P3 and so to the rules corpus, and moves at all three — the one #418 shape the gate can see, the other eight showing it only under an appended clause.

  The one reading the order costs: the consumer sees unjoined pieces, so "up to any trailing suffix" now means the first suffix WORD after the marker, and a connective beside it cannot un-suffix it first. `Jane née Jr y Jones` read maiden 'Jr y Jones' and now declines (family 'Jr y Jones'). The shape is a suffix homograph ANYWHERE in the maiden name with a connective beside it, not only in first position — review measured `Jane Smith née Jones Jr y Smith` going from maiden 'Jones Jr y Smith' to maiden 'Jones', family 'Jr y Smith', middle 'Smith': the maiden walk stops at the homograph, and the connective then builds a family name out of what is left, demoting the real one. The first draft of this entry priced it as the first-word shape, from a sweep that appended only ` née Jr Jones`. Accepted rather than built around, and the one place a merge-time guard would have read differently.

  #417's question — whether `van der Berg née` is a family name — is answered by consistency rather than decided: a marker the consumer declines is M2's "just a word" and rides inside whatever join reaches it, which is the reading `Jane van der Berg née` already had. The fields of `Jane van der Berg née Jr Jones` do not change at all (middle 'van der Berg née Jr', family 'Jones'); only the pieces do, which is why its pin is at the piece level. The invariant test that hid the shape now lists it.

  Review added two pins the first cut had left to prose. A corpus-wide assertion that appending a maiden clause moves no other field (tests/v2/test_parser.py), with #410's lone-residual shape as its one boundary until #410 closed it on 2026-08-25 — `Dr. Jane née Smith` reads family 'Jane' now, as `Dr. Jane` does, so the assertion covers every corpus name that parses to anything, and the only skip left is M2's own (nothing at all before the marker). And a field-level row for the chain-carried marker. One guard is deliberately left without a pin: `_is_maiden_marker_piece`'s lone-piece half has no default-vocabulary input that reaches it now that the pass runs first (dropping it leaves the suite and a 337k-name review sweep identical), and it stays as M2's own definition — "standing as a word of its own" — rather than as a guard a test holds.

Open (M2):
[#317](https://github.com/derek73/python-nameparser/issues/317)
the fullwidth-colon marker (旧姓：佐藤 arrives as one word; the head-peel question).

- 2026-08-22 #424 — the walk stops before the trailing numeral assign reads as the suffix. "Up to any trailing suffix" was asked with the suffix-piece test, whose initial veto does not see a bare `V`: `John née Jones Smith V` read maiden 'Jones Smith V' where `John Smith V` reads suffix 'V'. The walk now also stops at `_trailing_start` — the S2 peel read from the marker on, over the pieces as they stand, stepping over a tail segment's delimiter cores as the walk does — but at the NUMERAL only. The bare-acronym fork counts pieces, and the walk removes the very pieces it counted: `John née Jones Smith Ma` peeled over the pieces as they stand reads the acronym as a credential with words to spare, and once 'Jones Smith' has left the name it is the family of a two-piece name — maiden 'Jones Smith', family 'Ma', which is worse than the maiden 'Jones Smith Ma' it had always read. The numeral fork reads the piece before the numeral, and the take removes that very piece: afterwards assign sees the piece before the MARKER there, and where that is initial-shaped the fork does not fire — the first draft stopped the walk all the same, and `J. née Jones Smith V` read maiden 'Jones Smith', family 'V' (both reviews found it: 27 of the design-docs review's 144 marker shapes, every one a head ending in an initial; the pool is unrecorded). The walk runs the peel again over the view the take would leave — the pieces before the marker and the numeral on, built as P5's reserve builds its view — so it stops only where the numeral reads as the suffix both as written and as left: `J. née Jones Smith V` keeps maiden 'Jones Smith V', as `J. V` reads the V as a name, and `Dr. née Jones Smith V` keeps maiden 'Jones Smith V', since assign peels the title first and a numeral alone is no fork (the first re-ask checked the preceding piece only and handed that V to the given name — the code review). As written too: `Jane née Jones J. V` keeps maiden 'Jones J. V' though the take would leave `Jane V`, the words as written being what the marker takes; the second docs review found the statement saying only the second half. The acronym is left to assign, recorded as Accepted. The peel is read from the marker rather than after it, so `Jane Smith née V` declines as `Jane Smith née PhD` does — nothing after the marker but a suffix, the marker stays a word, suffix 'V' — which is 1.4.0's reading (the first draft read maiden 'V', and the 1.4.0 gate is what showed it); and `Jane née Jones J. V` keeps maiden 'Jones J. V' (the piece before is initial-shaped). 1.4.0 had no maiden support, so the examples fall under the #274 rule at that baseline where its fields reach, and `J. née Jones Smith V` (v1 read suffix 'V') under an accepted rule of its own; against 2.0.0 and 2.1.0 the examples that move are classified — `John née Jones Smith V` in maiden and suffix, `Jane Smith née V` in the five fields the marker's survival touches — and `J. née Jones Smith V` is 2.x parity.

- 2026-08-25 #410 — the maiden name no longer makes H1 decline; the rule counts name words and a marker-announced name is not one. The decision and its width are recorded at decisions.md#H1, since the term it removed was never maiden-specific.

- 2026-08-26 #434 — a maiden marker may be a PHRASE, and the Polish "z domu" ships as the first one. The rule had assumed a marker is one word everywhere it was written down, and the library's own advice made that assumption load-bearing: the dead-entry warning told a caller with a multi-word entry to "split it into separate entries", which for this field produces two wrong readings rather than one inert entry. With z and domu stored separately, "Maria Kowalska z domu Nowak" reads maiden "domu Nowak" — the marker's own second word inside the value #434 reports — and "Anna z Nowak" loses its family name outright, because a bare preposition is then a marker and M2 hands a marker every word after it. So the warning was not merely unhelpful for this field; following it was the defect. A phrase entry matched whole is the only shape that fixes both: z is an ordinary Polish preposition and domu an ordinary noun, and neither ever claims anything alone.

  A run predicate — identify a run of marker words, then look the joined run up — was rejected, and the reason is the difference between the two exceptions this makes. given_name_titles is the STORAGE precedent and NOT the matching one. Its entries are stored space-joined and folded per word (_lexicon._title_key, since whole-phrase normalization would leave 'lt. col' with its interior period), and markers copy exactly that. But its MATCHING identifies the run per word first — 'lt' and 'col' are each title vocabulary, so the parse has already read both as titles before anything is joined — and z and domu are markers in no vocabulary at all, so no run can be identified before the lookup. Markers therefore need genuine multi-token lookahead, longest first, which is a different mechanism wearing the same storage rule. Worth knowing that until this change NO shipped vocabulary set held a single multi-word entry, given_name_titles included: the exception existed for caller-configured vocabulary only, and "z domu" is the first phrase the library itself ships.

  That the phrase is safe where its words are not is the C-i question answered one level up, and the contrast worth drawing is with the neighbour in this section's Excluded block. #vocabulary-collisions C-i asks whether a word is borne as an ordinary name in the position the vocabulary claim acts on; roz fails it — Roz is a diminutive of Rosalind and stands exactly where M2's claim acts, the word after a given name — and MAIDEN_MARKERS has no ambiguous subset to express a partial answer in, so the only remedies available there were ship and do not ship, and it was removed. z fails C-i at least as badly. What changes is that the claim need not be made about z at all: the phrase is the unit that claims, and "z domu" is borne as no name in any position. C-i is a test on the claiming unit, and a phrase entry is a way of choosing a different unit rather than a way of passing the test with the old one. This is not a general escape — a phrase whose words are common and whose pairing is also common would fail C-i as a phrase — but it is the answer whenever the collision is with a word that only a longer phrase makes unambiguous.

  Markers are consulted at FOUR sites, which is one more than the title-run key's three, and _title_key's own docstring names the failure mode ("a divergence between them fails silently: the entry simply stops matching"). So one predicate answers "does a marker start here, and where does it end" — _pipeline._vocab.maiden_marker_run, in the vocabulary layer because it is a question about token TEXTS, per mechanisms.md#ONE-PREDICATE-PER-QUESTION's rule that the destination follows the layer. The four are classify's tagging, extract's clause test, and group's TWO — the piece test M2's take is built on and the drop inside an extracted clause (#329), which is the pair that makes four where titles have three. classify calls the predicate in a sequence pass, tagging a run's head "vocab:maiden-marker" as before and its continuations "vocab:maiden-marker-cont"; extract calls it over a clause's whitespace words; both of group's sites read those tags rather than re-deriving the run, which is the same mechanism's recorded-answer half, available because group runs after classify. tests/v2/pipeline/test_group.py::test_every_marker_site_ends_the_run_in_the_same_place is the contract, modelled on the P5/H1 title-run test: it collects each site's answer to "how many words is the marker" over nine spellings and asserts they cannot disagree, and each of the four sites has been mutated to confirm it fails.

  One divergence between those sites is deliberately PRESERVED and must survive any future unification. extract splits a clause on WHITESPACE while the tokenizer splits punctuation off, so "née," is marker-led to classify and not to M3. That is what keeps group's role filter reachable, and cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname is the row that pins it — 'Jane "née, Janie" Smith (née Jones)' reads nickname 'née Janie', maiden 'Jones'. Sharing the predicate did not merge the two questions, because each site hands it a different sequence of words; confirmed by mutation, where making _maiden_marked strip commas leaves that row (and its facade twin) the only failures.

  The commit order carries a finding about this repo's tests worth stating on its own. Taken in the plan's order — predicate, then storage plus the warning carve-out, then the sites — the middle commit would have exempted maiden_markers from the dead-entry warning before anything could match a phrase. That is not merely untidy: pyproject sets filterwarnings = ["error"], so a test that configures a phrase entry FAILS until the exemption lands, which means the carve-out cannot be deferred past the sites either. The two constraints leave one arrangement — the predicate alone, then storage and the four sites together, then the shipped entry — and the middle commit is fused for that reason rather than by preference. Shipping "z domu" last is what keeps any bisected revision from holding a phrase entry nothing can match.

  Review found one real defect, and it is the one this whole design exists to prevent. A marker run is TAGGED over the whole span-sorted token stream and CONSUMED over one segment, and the two populations are not the same: _segment.py:31 keeps only tokens extract has not already given a role, then buckets what is left by the commas before them. So a phrase written across a bracketed clause — "Anna z (domu) Nowak" — had both its words tagged while the piece walk could see only the first, and M2 took a proper PREFIX of the phrase for the whole marker: family 'Anna', maiden 'Nowak', a bare Polish preposition eating the name, and "Maria z (domu) Kowalska Nowak" losing its family name outright. Reachable on shipped vocabulary under the default policy, and it falsified three of this branch's own claims at once, in three files. The fix is a rule about the TAG rather than a guard on the consumer: classify refuses to tag a run whose tokens are not structurally contiguous — same role, so a clause edge ends a run, and the same comma bucket, computed the way segment computes it. Refusing rather than truncating, because truncating hands M2 the same wrong prefix one word shorter. The lookahead is bounded at the boundary before the predicate is asked, so a two-word entry refused at a clause edge still leaves a one-word entry starting there free to match, and an all-single-word vocabulary skips the walk entirely. rules.md#M2 now states the qualifier — a phrase is recognized only whole AND only where its words stand together — which it did not, and that gap is part of why the defect was invisible.

  The defensive complement was declined and the reason is worth recording: having _marker_run_pieces re-derive the run from token indices and refuse a mismatch would be a guard that, with the tagging rule in place, can never fire — untestable code standing in for an invariant one stage already guarantees. What it would have bought is written into that function's docstring instead, which now names _segment.py:31 as the second reason `seen` can skip a token; the sentence that said the continuation "is always in `seen`" without naming that reason is what made the gap invisible to review.

  The contract test should have caught this and did not, and the hole is worth naming because it is a property of how the contract was FORMULATED. It varied the marker's SPELLING across nine forms and held its PLACEMENT fixed, so every run it built was structurally contiguous by construction and no probe ever asked what the sites do when a run straddles a boundary — which is precisely where two sites walking different token populations can disagree. The placement axis is now its own test over eight placements, asserting two things no spelling can reach: that every tagged run has one role and one comma bucket, and that group drops a run whole or declines it. Mutation-checked in three pieces, which is what shows the two halves of the rule are both load-bearing: removing the contiguity bound outright fails both new case rows and all three straddling placements; removing only the ROLE half fails the two clause placements and both rows; removing only the COMMA half fails exactly one placement and NO case row, because a comma-split run happens to be declined downstream for an unrelated reason today. That last one is the argument for a tag-level contract test in its own right — the only witness to half the rule is an assertion about tags, not about fields.

  Perf, and how it was measured, because the first attempt at it was wrong in a way worth recording. Timing four source trees in separate processes and comparing them across sessions gave figures whose spread was the size of the effect: merged master alone measured 0.579s, 0.593s and 0.607s on one machine inside an hour. Every number below is a median of five INTERLEAVED rounds — every variant timed once per round, in order, so drift lands on all of them alike — over 8000 parses of a four-name mix (plain, titled-with-initials, phrase-marked, comma-and-particle). Against master: the run pass alone +6.8%, a head-word fast path inside the predicate +1.4%, the contiguity rule on top of that +1.6%, and after the simplify round -0.3% and -1.3% on two separate five-round runs. So the shipped mechanism has no cost this benchmark can resolve. That is not a free lunch and should not be read as one: classify no longer does the per-token `n in lexicon.maiden_markers` membership test master did — the marker decision moved into one pass that answers for every token at once — so what the marker pass adds and what it removed are within noise of each other. The reviewer reproduced the +7.2% and +1.6% stages independently on other hardware, which is the corroboration a percentage of this size needs.

  Four structural choices got it there, and each is a smaller version of the same move: ask the question once and pass the answer along. The fast path lives INSIDE the predicate rather than at its callers, so it stays an implementation detail and not a second answer. maiden_marker_head takes the FOLD rather than the text (suffix_as_written's shape, "n is _normalize(text), passed in so callers normalize once"), and classify folds once per token for the marker pass and the vocabulary tags together, which took the common token from two folds to one — fewer than before this work. maiden_marker_run folds each word once and keys from a prefix, where _title_key(words[:n]) per candidate length re-folds the whole prefix and made a ONE-word hit cost more than a two-word one; sixteen of the seventeen shipped entries are one word. And the marker pass returns its decision as index -> tag for classify's single token-building loop to write, rather than rebuilding every marker token a second time with dataclasses.replace, which is the most expensive call in a parse. The comma-bucket sweep the contiguity rule needs is deferred until a marker head is actually found, so a single-word vocabulary and a phrase vocabulary over a name holding no marker both skip it entirely.

  One limit is recorded rather than closed. The contiguity test is role plus comma bucket, and role is one-directional: a role change is always a clause edge, but two ADJACENT clauses of the same role are indistinguishable, so 'Jane (z) (domu) Jones' does tag a run across two separate nickname clauses. Nothing reads it — the piece walk never sees role-bearing tokens and the clause drop is scoped to one span — and the parse is identical with and without the phrase mechanism. The clean fix is a per-token structural UNIT id derived from state.segments and state.extracted, which would replace role-plus-bucket with one comparison and close the adjacent-clause case as a side effect. Deliberately not taken here: it changes a mechanism four correctness reviewers had just cleared, inside a pass whose stated business was not to change behavior. It is the shape to reach for if this test ever needs to grow a third term.

### N3 — the lone-word nickname rule

- 2026-07 (v2 core, PR #288; recorded plan deviation #2 of the core plan) — v1's rule counted pieces before grouping; the v2 port fires only when the nickname accompanies exactly ONE piece in total — a title counts against it, so "'Smitty' Dr. Jones" reaches H1 with a title and one name word left standing — through 2.1 that meant given="Jones" with the family empty, and since #410 (2026-08-25) H1 names the family, so it reads family="Jones". The count is unchanged; what moved is what happens after it declines. The rule lives in assignment because that is where the piece count is settled. (An earlier wording here said "one non-title piece", predicting the opposite output; the coherence review measured the truth.)

### N2 — same-character quotes and shared-character conventions

- 2026-07-18 #273 (7ee6e3a) — the typographic delimiter pairs shipped with a per-pair full-text scan; three independent review agents broke it the same day: with two shared-character conventions genuinely present, the earlier-sorted pair stole the close and the legitimate match dropped silently, zero ambiguity.
- 2026-07-18 (05fe693, the same day) — the interleaved leftmost-match restructure replaced it (an altitude review called it the right depth); the author's own first fix — post-hoc offset filtering of unbalanced candidates alone — was superseded within the day and survives only as the bulk-recorded dangler filter rules.md#N2 now names. The word-internal carve-out covers ONLY the straight apostrophe because it is the one delimiter character that occurs mid- and end-of-word in real names; Derek's distinguishing test: with double quotes the same shape ("Mari\" Aube\"") is genuinely ambiguous, while Mari' Aube' is not. A cached delimiter-charset prescreen landed in the same commit, measured: no-delimiter names parse faster than the pre-#273 baseline.

Declined:

- The offset-filter as the whole fix — it repaired the reported symptom (spurious unbalanced flags) while leaving the stolen- close mis-extraction in place.

### ma-do — ambiguous acronyms by decision

- 2026-07-17 (M12, Derek-approved) — ma and do joined the ambiguous acronym set because both are common surnames; the two-word "Jack Ma" is kept intact by S2's words-to-spare guard, while the periods gate governs the dotted spellings ("M.A." counts unambiguously). Documented side effect: parenthesized bare
  "(MA)"/"(DO)" no longer escape to suffix as in 1.x.

### vocabulary-collisions — when a word earns the ambiguous marking

The mechanism shipped twice before anyone wrote down its criterion. Sizes as of 2.2.0dev: particles 67 with 39 ambiguous (58%, PARTICLE_OR_GIVEN); suffix_acronyms 613 with 4 ambiguous (0.65%, SUFFIX_OR_NAME); titles 711 with no ambiguous subset and no AmbiguityKind at all. Those counts are evidence from the decision date, not live facts — they drift with every vocabulary addition. The argument does not depend on the digits (it depends on the two shares differing by orders of magnitude), but recompute before quoting them:

    uv run python -c "from nameparser import Parser; L=Parser().lexicon; print({s: len(getattr(L,s)) for s in ('particles','particles_ambiguous','suffix_words','suffix_acronyms','suffix_acronyms_ambiguous','titles')})"

- 2026-08-16 (collision keystone; #348, #360, #342, #385) — the 58%-vs-0.65% gap is BASE RATE, not disagreement. Both sets apply the same test; most particles are short words that double as names (van, bin, le, do, bar, mac) while most credential acronyms are not (abpp, acp). Recorded because the gap reads as an inconsistency and is not one — a reviewer who "harmonizes" the two shares will break one of them.
- **C-i, vocabulary vs. name.** A word belongs in its set's ambiguous subset iff it is borne as an ordinary name IN THE POSITION THE VOCABULARY CLAIM ACTS ON. Existence of a bearer anywhere is not the test, and the first draft of this criterion (recorded 2026-08-16, corrected 2026-08-17) got that wrong. Under uncertainty, default to AMBIGUOUS. This generalizes the evidence standard already stated in NON_GIVEN_NAME_PARTICLES' docstring — a wrong unambiguous claim misparses a real person, a wrong ambiguous marking only adds a flag — from "which set" to "which subset". Applies uniformly to particles, suffix_acronyms and titles.
- **C-ii, vocabulary vs. vocabulary.** Where two sets claim a word and NEITHER reading is a name, precedence is a frequency judgment recorded per word. vd is the live case: never-given particle AND credential acronym (the British Volunteer Decoration), neither of them a name. Decision: the Dutch van der reading, as the more common. That is what unblocks #380, whose trailing-orphan half is a separate decision recorded under its own rule.
- 2026-08-17 — C-i CORRECTED: the position qualifier. Writing the
  per-word records for the never-given particles falsified the first
  draft on the set's most load-bearing member. "De" is a borne Bengali
  and Odia surname (parse("Bimal De").family == "De"), so C-i as first
  written said mark `de` AMBIGUOUS -- which would break "de la Vega"
  and every leading-particle reading. Nothing had tested the criterion
  against `de`, and the error pointed at the safe side, so it would
  never have announced itself as a misparse.
  The qualifier the existing set was already obeying: P1 acts on the
  LEADING position or a lone particle in the GIVEN role, so what
  matters is whether the naming use occupies THAT position.
    van   Vietnamese Văn in given position     collides   ambiguous
    bar   Bar Refaeli, given position          collides   ambiguous
    do    Đỗ leads a surname                   collides   ambiguous
    de    "De" is a TRAILING surname           no clash   never-given
  Consequence, and the reason this is not merely tidier: `das` was
  called ambiguous on #360 under the old reading because Das is a
  borne Bengali surname. Measured, "Anjali Das" and "Bimal Das" are
  unchanged by never-given `das` (the surname is trailing, the rule
  acts leading) while "Maria das Neves" GAINS its particle --
  family="Neves" today, family="das Neves" with it. The old reading
  would have declined a fix. `lo` and `el` need re-judging on the same
  axis; `Lo` does lead in romanized Chinese ("Lo Wei"), so it may
  genuinely collide where `das` does not.
  Corrected on #360, which carried the wrong table publicly.
- The concrete demonstration is "do", which three vocabularies claimed — titles, particles/ambiguous, suffix_acronyms/ambiguous. Two mark it ambiguous; the third, TITLES, was the one that actually decided "Do Quang Minh" (title="Do", given="Quang") and reported nothing — until #296's audit (2026-08-23) took it out of TITLES, since when the name reads given "Do" and reports the fork. Same word behind #385's "Anh Do".
- Applications, each still its own work: #360 (mc, ste — neither is a borne given name, so both leave the ambiguous half); #342 (rai — Rai IS a borne surname, so it earns the marking rather than moving); #385 (do — resolved at decisions.md#R2).
- C-ii's per-word framing versus a rule stated for a SHAPE: measured 2026-08-16, the words that are both particle and suffix vocabulary are vd, do and mc — three, not the one this criterion adjudicated. rules.md#P6 states its precedence for the shape, so do and mc inherit vd's answer without being weighed. Recorded rather than papered over: stating a per-word judgement as a general clause is how an unexamined word acquires a decision, and the two are named here so the next reader knows which one was actually argued.
- Caution when applying C-i to the particle set: TITLES ∩ ambiguous — {do, freiherr, st} until #296's audit took 'do' out of TITLES (2026-08-23; `Do Quang Minh` reads given 'Do' with the fork now), {freiherr, st} since — is load-bearing, per the Excluded note in the W2 section. Emptying it makes the particle-or-given emitter dead code.

Open: [#348](https://github.com/derek73/python-nameparser/issues/348) applying C-i to the 711 title entries, then titles_ambiguous plus a TITLE_OR_GIVEN kind. Blocked on data, not on judgement — the census needs a given-name frequency corpus this repo does not have, which is why the criterion is recorded here and the census is not attempted.

### suffix-field-composition — three kinds of thing in one field

- 2026-08-16 (suffix keystone; #326) — measured composition of suffix_words (40 entries): 11 generational (i, ii, iii, iv, v, jr, jnr, sr, snr, junior, 2), 5 neither (dr, esq, esquire, ret, vet), and 24 POSTNOMINAL HONORIFICS — 20 CJK (さん, さま, くん, ちゃん, 様, 殿, 氏, 先生, 博士, 教授, 女士, 小姐, 씨, 양, 군, 님, 박사, 박사님, 교수님, 선생님) and 4 Hebrew (ז"ל, ז״ל, שליט"א, שליט״א). The honorifics are the LARGEST group.
- Decision: do NOT split the field. Record the composition; #296,
  #291, #325 and #289 proceed on their own terms rather than
  waiting on it.
- Why #326 cannot be taken at face value: it argues the split is tractable because "the vocabulary is already split — suffix_acronyms is credentials, entirely; suffix_words is generational plus a handful". That was true when written (2026-08-02); the 2.1.0 East Asian work landed 2026-08-07 and put the 24 honorifics in the same set. CLDR's two buckets do not cover what the set now holds, so "adopt CLDR's model" is not available as the cheap answer. The issue's table is corrected on the issue.
- The unexamined question this leaves: whether the honorific bucket belongs in `suffix` at all. rules.md#W3 already argues an honorific is "no part of the name on either side" — the same language the H section uses for prenominal titles — so the conflation with PhD may be worse than the generation/credentials one #326 was filed about. Deliberately not decided here.
- Recorded as a documentation failure mode, not just a fact: `suffix` started generational, absorbed credentials, then absorbed postnominal honorifics, each step locally reasonable and none recorded as a widening of the field's MEANING. rules.md#S2's Background still calls it "two different things", which was accurate when written. The rules doc pinned the behavior faithfully; what slipped is the field's definition, which no rule states because no rule owns it. Field definitions need the same discipline rule statements get.
- The 11/5/24 split above is this entry's own version of the hazard it describes: a quoted vocabulary composition, dated, exactly as
  #326 quoted one. A date does not stop rot — #326 carried one too
  — so before relying on the split, check the set still looks like it:

      uv run python -c "from nameparser import Parser; print(sorted(Parser().lexicon.suffix_words))"

  What is durable here is the SHAPE of the finding — three kinds of thing, the honorifics the largest — not the three integers. A test asserting the counts is deliberately not the answer: that is the constant-content pattern, and it would fail on every legitimate vocabulary addition.

### deviates-registry — packs stay pure data (option C)

- 2026-07-18 (d4aaafa; the DEVIATES design note) — a `deviates` field ON Locale was rejected: callables break value equality and pickle-by-reference, and DEVIATES is acceptance metadata, not runtime behavior (revisit only if a runtime consumer like explain() appears). The REGISTRY became the contract instead: test_registry_is_the_pack_contract fails structurally unless every registered pack ships DEVIATES and its rotator list. This is why LOCALE-PACKS-PURE-DATA can promise "no code paths of their own" and mean it.

### normalization-fold — lower(), not casefold(); comparison folds harder

- 2026-07-17/19 — vocabulary storage normalizes with lower(), NOT casefold(): casefold mutated stored spellings (κος→κοσ, großfürst→grossfürst) while lower() keeps authored forms; the accepted cost, pinned deliberately, is that ASCII-SS GROSSFÜRST no longer matches. The paired half: comparison_key()/matches() use casefold() ON PURPOSE — comparison is the one surface that wants aggressive folding; this is a release-log-classified 2.0 CHANGE from 1.4's lower() (ß and final-sigma forms compare equal only since the v2 core; v1.4 has no casefold anywhere). The 1.x line was symmetric BY DESIGN — lower() everywhere, chosen for symmetry with the parser's lc() convention — so the storage-vs-comparison split is a v2 decision moving away from a real, deliberate alternative, not a fix of an oversight. Do not
  "fix" it symmetric: symmetric is what 1.x was.
- 2026-07 (rc1 arc) — the fold must reach a FIXED POINT, and anything built on it must converge too: _title_key joined per-word folds and kept empty slots, so given_name_titles with a foldable word stored a key match-time could never rebuild — silently inert, and pickle round-trips then rejected the state. Shipped in the rc, caught in review, fixed by dropping words that fold away.

### render-default — the default view's format

- 2026-07-18 (bf1141c) — the default spec is '{title} {given} "{nickname}" {middle} {family} ({maiden}) {suffix}'. Declined the same day: a née-template maiden ('née {maiden}') that round-tripped LOSSLESSLY (née is a maiden marker), rejected on presentation grounds. Accepted cost of the shipped form: a parenthesized maiden re-parses as a nickname.

### P3 — connectives

- 2026-07-30 #267 — the four-word single-letter asymmetry: a bare Latin capital connective is vetoed as an initial while a Cyrillic capital joins. #267's closure ("v2.0 behaves this way by default … verify it was the right call") showed only the Cyrillic half; the Latin-capital veto was never separately adjudicated, which rules.md#P3 records as an Accepted consequence.
- 2026-08-17 — a conjunction-joined run counts as ONE name word in every rule that counts them. Raised while bounding the leading particle run (#395): a run that stops "after the first name word" would otherwise be able to stop INSIDE a part P3 built, splitting "de la Vega y Santos Juan" between "Vega" and "Santos". This is not new behavior — H1 already counts this way, measured: "Mr. Jack and Jill" gives family="Jack and Jill" while "Mr. Jack Jill" gives given="Jack", family="Jill". What is new is that rules.md says so, which is what binds #395's implementation. Recorded because the first draft of the clause asserted that no rule could show it today and shipped an Accepted note to that effect; the shipped H1 pair was simply never run. Detector failure of the usual shape — reasoning about which rules count instead of parsing a name and looking.

- 2026-08-21 #418 (PR #420) — the three-word carve-out counts the name that remains once the maiden name leaves. The count was taken while the marker and the maiden name were still pieces, so `juan y garcia nee jones` counted five, 'y' joined, and the family was empty once the clause left — given 'juan y garcia', family ''. Nine corpus names show it under an appended clause. Closed by order, not by an exclusion: the marker pass now runs before the count (decisions.md#M2, 2026-08-21), and the three-piece gate ahead of the count, which had the same exposure, is closed the same way. rules.md#P3 now says the count is of the name's own words. The question #397 and #411 left open — whether a word's membership in one vocabulary should suppress it from a count gating a rule keyed on another — is untouched: this was never a membership question, the words are simply not in the name.

Open:
[#383](https://github.com/derek73/python-nameparser/issues/383)
should the Latin-capital veto stand (bless / drop / extend).
- Provenance: the single-letter-connective guard is v1's fix for Google Code issue 11 ("john e smith", 2013, commit 33676c9) — the "#11" citations that circulated pointed at a GitHub accident, not the real source. Recorded so the archaeology stays done.

### given-name-titles — deliberately unvalidated

Declined (rc1 arc; the full argument is AGENTS.md's gotcha):

- Validating given_name_titles against titles, twice: the whole-entry check rejected legitimate multi-word entries; the per-word check rejected "sir and dame" (a conjunction inside a title run is itself a TITLE token, so the key is matchable while its middle word lives in conjunctions). No static relation over the vocabulary sets decides reachability, and an unreachable entry is inert — each guard cost a working configuration to forbid a condition that costs nothing.

### phd-merge — the "Ph. D." split

- 2026-07 (v2 core, PR #288; recorded plan deviation #1 of the core plan) — "Ph. D." tokenizes as two words and is merged back by vocabulary (v1 fix_phd), so the spaced and unspaced spellings read alike. (Scoped by #371 below: alike everywhere EXCEPT at the head of the string, where the merge declines and `Ph. D. Van Johnson` reads title 'Ph.' where `Ph.D. Van Johnson` reads given 'Ph.D.'.)

- 2026-08-31 #371 (the merge declines at the head) — a suffix never BEGINS a name: position outranks the vocabulary match, and `rules.md#S2` now states it. The merge is what made a leading credential possible at all — every other suffix-shaped word standing first already falls out as a title (H2's abbreviation clause, or TITLES membership: `Jr.`, `MD`, `Esq.`, `Sr.`) or as a name word (`PhD`, `III`), so the `Ph.`/`D.` pair was the only shape that reached the defect. It reached it by emptying the family: `Ph. D. Van Johnson` read given 'Van Johnson' with no surname at all, which is #371's report.
  THIS RESTORES 1.4.0 RATHER THAN DEVIATING FROM IT — on three of the four names, and the fourth is named below. v1's fix_phd healed the pair by regex on the raw string, `\s(ph\.?\s+d\.?)`, whose leading `\s` made a head match impossible while it fired everywhere else, mid-name as readily as trailing. (A first draft of this entry said "only where it TRAILED", which is false in both directions and would license narrowing the merge to the trailing position — `John Ph. D. Smith` reads suffix 'Ph. D.' at 1.4.0 and here.) At the head v1 split it — measured on the released wheel, `Ph. D. Van Johnson` gives title 'Ph.', first 'D.', last 'Van Johnson', which is exactly what this change gives. So `expected_since_1.4.0.toml` LOSES its fix(leading-credential) rule — and READ THE DIAGNOSIS, not just the error: the gate reported it SHADOWED, not reverted. `compare.py` separates the two ("shadowed -- an earlier rule claimed every diff it would have" versus "reverted -- matches no diffing name") because the fixes differ. Three of its four names became parity and the fourth, `Ph. D., Jr.`, still diverges under `fix(comma-precomma-family)`, which sits above it. #426 is the precedent for dropping a shadowed rule rather than keeping it. A first draft of this entry generalized EXPLAINED NOTHING into "how a parity restoration announces itself", which would teach the next maintainer to delete a shadowed rule whose behavior is still diverging and the 2.0.0 and 2.1.0 ledgers GAIN one, claiming the same four names, the same five roles and the same digest. What shipped in 2.0 was a regression from applying the merge unconditionally, not a reading anyone chose.
  OPENING THE NAME IS NOT OPENING A PIECE LIST, and the first draft got that wrong: a credential run legitimately opens segment 1 after a family comma (`Smith, Ph. D. Jr.`), which C1 reads as a listing rather than a name beginning. Testing `k == 0` alone broke fifteen tests and took the gate red at all three baselines. The caller passes the distinction in, since `_group_segment` cannot see which segment it holds.
  ACCEPTED, and found by review rather than by design: a TITLE before the pair keeps the credential reading, because `k` is a piece index and a title is a piece. `Sir Ph. D. Van Johnson` still reads given 'Van Johnson' with an empty family — #371's own symptom, surviving one word to the left. It is 1.4.0 parity, and the rule states it as a boundary rather than deviating: "the first piece of the name" cannot be computed before this merge, since H2's abbreviation test is true of `Ph.` itself, so a scan that stepped over titles would step over the very piece being judged. rules.md#S2 therefore says OPENS THE STRING, which is what the code holds and what v1 held.
  THE FIRST DRAFT GOT "THE HEAD" WRONG IN THE OTHER DIRECTION, and the corpus could not see it. Testing the PIECE index alone declined the merge for `"Bob" Ph. D. John Smith`: `extract_delimited` removes a quoted or bracketed clause before grouping, so the pair reaches the merge at `k == 0` with a word standing before it in the INPUT. v1 merges there (its regex needed only a preceding space), and 112 measured names broke parity — every one opening with a quote or a bracket, and not one of them in any corpus. The test is now `a[0] == 0`, the first TOKEN, which is v1's boundary exactly.
  ACCEPTED: `Parser.revise(suffix="Ph. D.")` now renders 'Ph., D.'. revise() runs a full sub-parse of the string it is given, and a field value has no head for a head-position rule to consult; a second draft carved that out by requiring a name to displace, and the carve-out made the head reading depend on what FOLLOWED it — appending a maiden clause changed whether 'Ph.' was a title, which `test_a_maiden_clause_changes_nothing_else` caught. Dropping the carve-out removed the inconsistency with it. The merge exists for a credential someone TYPED after a name; a caller who writes the spaced form into the suffix field is taken at their word (Derek's call, 2026-08-31).
  What the leading `Ph.` becomes is H2's business, not this rule's: an abbreviation before a name is almost always a title, which is the same clause that reads `Esq. van Gogh` as title 'Esq.' — so the pair reads title 'Ph.', given 'D.'. The issue proposed given 'Ph.', middle 'D.'; H2 claims the first word before the positional read sees it, and the two answers differ only in which field holds `Ph.`.

### O4 — positional assignment and declared order

- 2026-08-07 #83 — romanized Chinese order is answered by DECLARATION, not detection: pinyin carries no signal and diaspora makes locale no guide (the issue's own 2019/2021 conclusions); Policy(name_order=FAMILY_FIRST) is the answer the thread wanted.
- 2026-08-07 #146 — Vietnamese needs the THIRD order constant: measured on "Nguyễn Thị Minh Khai", FAMILY_FIRST strands given="Thị" while FAMILY_FIRST_GIVEN_LAST reads given="Khai", middle="Thị Minh", family="Nguyễn". This is why three order constants exist rather than two.

- Provenance: the three-constants argument originates in #270's own body (2026-07-07): "A boolean family_name_first flag was considered and rejected: Vietnamese order is
  [family][middle][given]." The 2026-08-07 #146 measurement below
  is the verification; #270 is the origin.

Declined:

- Free-form order tuples (('last','middle','first')-style, #270's original draft shape) — the shipped design is exported order constants with tuple rejection at construction (rule D2's
  [bad-name-order] example is the pinned message).
- middle_as_family as the way to suppress the middle slot for Vietnamese (2026-08-07 #146) — measured: it merges the middles into the family, giving family="Thị Minh Nguyễn" for "Nguyễn Thị Minh Khai" under family-first-given-last — a plausible-looking wrong answer. There is no policy field that suppresses middle, and name_order rejects a two-role tuple ("name_order must be one of the exported orders"); given_names (given + middle) is the view that stays correct wherever the internal boundary falls.

### W4 — script-scoped order

- 2026-07-27 (script-scoped order amendment) — the family-first override is keyed to the SCRIPT of the written name, never to a guessed language: wholly-Han, wholly-Hangul, and kana-licensed Japanese read family-first because zh, ko and ja all write family-first in native script; wholly-katakana names are predominantly transcriptions and keep the declared order. Latin transliterations are never touched.
- 2026-07-29 #272 — the kana license: Han∪kana with at least one kana cannot be Chinese and is not a transcription, so 高橋みなみ reads family-first though it is written in two scripts.

### D1 — the segmenterless-activation warning

- 2026-08 #337 — WARN rather than raise, deliberately: registering the JA pack without the extra must stay safe (the inert registration is itself a pinned property), and a warning is filterable by the caller who wants exactly that inertness. The Japanese install hint is conditional — it appears only when a Japanese script is among the dead scripts; a hangul-only gap gets the generic remedies.
- 2026-08 #337 — the warning exists because parser_for(locales.JA) without segmenter= used to build a parser that silently behaved like a working one minus the feature; it re-emits from the parser_for frame so the reported location is the caller's own call.
- 2026-08-07 #339 — diagnostics that hand the reader code must hand code that type-checks: the message's offered deactivation used a bare tuple literal for segment_scripts — an arg-type error under mypy in a py.typed package — and became frozenset(), pinned by a test asserting the offered spelling. The known-bad spelling is in the denylist test. The structural fact behind the recurrence: autodoc renders docstrings into the API reference, so docs/*.rst is NOT the boundary of "the docs" — a guide and the reference can teach different spellings on the same rendered page, invisibly to any .rst-only sweep.

### D2 — construction raises, parse never does

- 2026-07 (v2 core, PR #288) — every raise in the locale-apply path is a plain TypeError/ValueError so the wrap-with-locale-code rewrap cannot break on exotic exception signatures.

Declined (ambiguity kinds for script-resolved names, 2026-07-27):

- A "han-script" zh-vs-ja kind — applying the ZH pack IS the disambiguation; per-name flags after an explicit opt-in are noise.
- ORDER emission for script-resolved names — native-script order is convention, not a guess; the reserved kind stays unemitted. SEGMENTATION fires only on a genuine multi-split vocabulary fork (夏侯惇, 남궁민수).

### H1 — a title and one name word

- 2026-08-25 (#410) — H1's "and nothing else" counted a suffix, a nickname and a maiden name as further name words, so a title-plus-surname name reported no family the moment any of them stood beside it: `Dr. Smith` reads family 'Smith' and `Dr. Smith née Jones` read given 'Smith' with the family empty. #410 reports the maiden flavor; measured, all three roles suppress the rule identically and the fix is one term in one guard, so the term went rather than the maiden role being special-cased. What decided the width is H1's own rationale — a title addresses by surname — which says nothing about what stands BESIDE the name. The rationale is stated for H1 rather than as a doc-wide principle about what a suffix is, deliberately: N3 counts a suffix the other way (`'Smitty' Jones Jr.` reads given 'Jones', family ''), and which of the two readings is right for a nickname-led name is not decided here. #399 is what made this urgent rather than latent: stopping the particle chain at the marker routed the canonical title-and-particle shape (`Freiherr von Richthofen geb. Albrecht`) into H1 for the first time. N3 moves with it, without N3 changing: `'Smitty' Dr. Jones` declines N3's one-piece count as it always did, and H1 now names the family behind it, so it reads family 'Jones' where it read given 'Jones' through 2.1. Five corpus names change reading at every baseline, of which four arrive as new diffs and are classified with the fix. The fifth, `Freiherr von Richthofen V`, was already classified under the `fix(#424)` rule: #410 narrows its diff from {given, family, suffix} to {family, suffix}, and `classify()` takes the first rule whose declared `fields` are a SUPERSET of the observed diff, so a shrinking diff kept matching and no run ever named it. A real movement behind a green gate, and the second of that kind here: the fields-only `fix(suffix-routing)` catch-all was absorbing three names at 1.4.0 the same way, its heading growing with nothing to announce it. Both are one shape — a rule broader than the diff it explains — and that is the lesson worth keeping rather than either fix. It is also, being a title, one name word and a suffix, the suffix flavor of this very shape, which two ledger comments had claimed no corpus carries. At 1.4.0 three of the four were being absorbed by the fields-only `fix(suffix-routing)` rule (its heading went 14 -> 17) — invisible to `_CORPUS_CLAIMS`, which records the whole corpus for a rule with no `name_regex`, so the guard could not see them arrive. They have their own rule now, as #372's names got one off the same catch-all. The v1 suite shipped the nickname reading as a strict xfail (`tests/test_nicknames.py::test_nickname_and_last_name_with_title`), which passes now. Known gap between the statement and the guard: H1 says "exactly one name word" and "that word", but the guard never counts units. It tests which ROLES are unoccupied — no middle, no family — and then retags every given token, so a name whose given run is two name WORDS (units, not tokens) reaches it and moves both: `Freiherr de V Jr` reads family 'de V'. The distinction matters for reading the claim that follows: a multi-token given run is ordinary — the rules.md example `Freiherr von Richthofen V` has one, its two tokens being a single particle unit — and it is a run of two UNITS that no real input produces. A 58,338-name sweep found no corpus name and no rules.md example with one — but it is recorded because decisions.md elsewhere leans on H1's "exactly one name word" as a settled scope when narrowing P5's licence, and that reading of H1 is stronger than the code behind it. Not fixed here: the widening this entry records is about which roles disqualify the rule, not about how it counts.

### H2 — the leading-abbreviation title

- 2026-06-30 (leading-period-title design; v2 core, PR #288) — the shape test is v1 parity (period_abbreviation): two-plus letters then a period, bare initials exempt. Its site is the head of the part CARRYING THE GIVEN NAME — the whole name, or the post-comma part under a family comma — not "the head of the name"; that scope correction is PR #315 (2026-08-01, docs-only), verified against 1.4.0 from PyPI, so the parity claim is real and the narrower description never was. The extraction litmus (2026-08-15): the spec drafted this rule as
  "recognized by vocabulary, not by written shape" and the live
  parser falsified that framing — the shape heuristic is real, and what the abugida gap (#343/#344) shows is its LIMIT, not its absence. Recorded as the rule's Accepted consequence — and the 2026-08-15 landing initially re-narrowed the scope to "name- opening", correcting one error while preserving another; the eighth review round fixed it.

Open: [#316](https://github.com/derek73/python-nameparser/issues/316) what a trailing title-vocabulary word should do (the comma paths disagree today).

### W1 — unspaced CJK division

- 2026-07-27 #271 (decision; shipped in 2.1.0 via PR #294) — Korean division ships as a default: the census surname list is closed, hangul is self-selecting (a hangul entry can only match hangul text), and being unsplit is recoverable while a wrong split is not — which is also why an unrecognized name stays whole. The filed proposal (#271, 2026-07-07) asked for OPT-IN segmentation for Korean too, "like all localization"; default-on is the later refinement, and the census/self-selecting argument above is what justified promoting Korean past the blanket opt-in stance.
- 2026-07-29 #272 (the ja amendment; shipped in 2.1.0 via PR #297) — Han division is opt-in per language pack because Han text does not identify its language (高橋一郎 under a Chinese list divides wrongly); a pluggable segmenter takes what the vocabulary declines, so pack + segmenter compose mechanically: a listed surname is a dictionary certainty and wins.

- 2026-07-29 (ja amendment §1a) — script CLASSIFICATION NFC-normalizes its input (NFD katakana carries combining marks outside the block; NFD hangul decomposes to jamo, which would miss the order rule for macOS-origin names) while segmentation MATCHING stays raw — chosen over offset-mapping complexity because NFD then degrades to no-split, never to a wrong split. The read-only fold is the one deliberate exception to "nothing rewrites the text" (rules.md T Background), and it never enters a token.

Declined:

- JMnedict as bundled segmentation data (#272's body, 2026-07-07) — the pip packaging (jamdict-data) was last compiled 2021-04; JMnedict carries no frequency data, so it cannot resolve the ambiguous 2+2/3+1 splits that motivate a segmenter at all; and bundling raised licensing questions the core avoids by delegating to namedivider — whose code and GBDT model are MIT, whose surname data carries Myoji-Yurai terms permitting exactly this name-dividing use, and whose CC-BY-SA BERT model is unused (the 2026-07-29 amendment corrected an earlier flat-"MIT" description; so does this entry). The kind of decline someone re-proposes in two years.

- 2026-08 — zh and ja packs are corpus ALTERNATIVES, one per corpus; stacking them (parser_for(ZH, JA, segmenter=...)) is for genuinely mixed data that accepts the trade: a listed Chinese surname wins before the segmenter is consulted, so 高橋一郎 still divides 高 + 橋一郎 under ZH+JA exactly as under ZH alone. Kana gating resolves through the same script function order uses, so kana-licensed composites (高橋みなみ) gate in under JA while pure-katakana tokens never do (amendment 2026-07-27: activation is per script because the ambiguity is per script).

### W3 — the writer's divisions are respected

- 2026-08 (segmenter amendment 2026-07-29) — a segmenter answers where an UNDIVIDED name divides, so it is consulted only when the gated token is the name part's only script-written one: "山田 太郎" was divided by its writer and must not have its family divided again. The peeled tail is exempt (that boundary was manufactured, not written); a SPACED honorific is not — at that position it cannot be told from a spaced name element. The trade was measured before deciding: counting spaced honorifics keeps four real surnames whole (佐藤 氏, 田中 様, 鈴木 先生, 中村 教授) and costs the one division 山田太郎 様.
- 2026-08 #312 — under FAMILY_COMMA the whole stage stood down until the peel moved in front of the gate: the comma doctrine is about the input's structure, not the split's source, so it covers vocabulary and segmenter identically, while an honorific is no part of the name whichever side of the comma it glues to.
- 2026-07 — the stage runs AFTER comma segmentation on purpose: running before would make the comma structure depend on the split ("김민준, Jr." pre-split would read suffix-comma on vocabulary alone; as written it stays the listing form).

### W2 — the glued-honorific peel

- 2026-08 #308 — an entry peels only where it can never end a name: 씨/님/さん/様/先生 peel; 양/군/氏/博士/殿 stay spaced-only; 君 is in neither set while its kana spelling くん peels. The vocabulary carries the license, so no structural or per-script gate stands over the peel.
- 2026-08 #312 — the peel crosses the family comma and the 间隔号: both answer where a name DIVIDES into surname and given, a question the peel never asks. Provenance matters here: neither gate was argued for the peel specifically — both came with the placement (#312's own framing) — so the crossing was a repair of inherited gates, not a designed-in property.
- 2026-08 #319 — a wholly suffix-shaped second run is declined as a peel site (the "田中さん, V." shape), but only when the name's own run offers a site, since a glued honorific is itself part of what makes a run read as suffix-shaped.

Excluded (the never-given / ambiguous particle line, nameparser/config/particles.py — #360 owns the vocabulary question):

- Only 9 of the ambiguous members were ever individually justified; the rest sit there by the conservative default (ambiguous unless argued never-given).
- mc, ste — measured misparses ("Mc Donald" → given "Mc"), tracked in #360; st is inert at the head because TITLES claims it first; mac must stay ambiguous because Mac is a real given name.
- Encoding rationale (#293, predating #360's membership questions): the data layer stores the NEVER-GIVEN set and derives the ambiguous one, because that is safe-by-default for new particles — a one-place addition — and the v1 shim translates by one-directional complement. And the constants are FROZEN specifically to kill the cached-Lexicon.default()-vs-fresh- Constants desync that runtime module-constant mutation caused.
- Load-bearing dependency: TITLES ∩ ambiguous — {do, freiherr, st} until #296's audit, {freiherr, st} since — is what keeps the particle-or-given ambiguity emitter reachable at all; moving them all would leave the shipped vocabulary unable to reach it, which is why test_the_chained_emitter_is_reachable_by_construction (tests/v2/test_parser.py) pins reachability by construction rather than by shipped vocabulary — an empty intersection fails no test and does not mean the emitter is unreachable.

Open (contested vocabulary memberships — the rule is right, the word's set is questioned; the issue is canonical):
[#342](https://github.com/derek73/python-nameparser/issues/342)
rai in SUFFIX_ACRONYMS vs. the South Asian surname ·
[#346](https://github.com/derek73/python-nameparser/issues/346)
swami and the renunciate titles absent from the given-name-title set ·
[#343](https://github.com/derek73/python-nameparser/issues/343) /
[#344](https://github.com/derek73/python-nameparser/issues/344)
Bengali and Devanagari honorific vocabulary.

Excluded (SUFFIX_ACRONYMS / SUFFIX_WORDS — the esq dual membership, deliberate; AGENTS.md's gotcha carries the full algebra):

- esq is in BOTH sets and must not be "deduplicated". The load-bearing membership is the acronym one (it carries the multi-dot spellings: removing it costs "John Smith E.S.Q." its family name); the word membership is inert as shipped but is what keeps "Esq" matching for a caller who edits suffix_acronyms themselves. esq is the ONLY member of SUFFIX_ACRONYMS ∩ SUFFIX_WORDS — that singleton is why the two sets cannot carry a disjointness assert, which is the standing cost this entry defends. Deliberately no changed-parse count — the count is a property of the measuring grid, not of the code.

### P5 — bound given names

- 2026-06-30 (first-name-prefix-join design; v1-era, carried into the v2 port) — the join is vocabulary-driven and deliberately tiny.

- 2026-08-18 — `abd` ADDED, and the reserve count corrected to let it work. It is the transliteration for the spellings that write the article as its own word ("Abd Allah", "Abd al-Rahman"), which abdul/abdel/abdal do not match; the Arabic-script عبد has covered the same word since #269, so only the Latin side was short. Not a C-ii collision, though it looks like one: C-ii settles a PRECEDENCE by frequency where neither reading is a name, and here one reading is a name and neither had to be given up. It is C-i's positional qualifier (2026-08-17) applied to a bound-given word — the naming use is LEADING, the credential use trailing, which is the `de` row of that table ('a TRAILING surname — no clash') rather than the `vd` row. `abd` is also the postnominal ABD ("All But Dissertation") in SUFFIX_ACRONYMS, and all three credential spellings still parse ("Jane Smith ABD", "Jane Smith, ABD", "Jane Smith A.B.D.").

- 2026-08-21 #411 (PR #416) — the reserve counts the name the join will actually leave behind, and the join never absorbs a marker. P5 has always said the join "needs a name word to spare, so two name words alone do not join"; the count was taken while the maiden marker and the maiden name were still pieces, because group's marker pass runs later in the same stage. `abdul Berg née Jones` counted four words, joined, and left no family name at all — given 'abdul Berg', family ''. That spelling is the pre-existing witness: it reads the same way at 2.0.0 and 2.1.0. `abd` does NOT — it reads correctly at 2.1.0 and acquired the defect only when #400 made it a bound given-name word this cycle, so for that spelling this is a regression #400 introduced and this closes. #399's chain stop widened the class again by putting the particle spellings in front of the reserve for the first time.

  Excluding the departing words is not sufficient on its own, and review is what established that. Where the maiden walk stops at an inner suffix the excluded span is short, enough words survive it to clear the threshold, and the join fires and takes the MARKER — after which nothing leaves and the count that authorised the join was reasoning about a name that never came to be. So the join now declines outright when the piece it would absorb is a marker, which is what P5 meant all along: it joins the bound word to a name word, and a marker is not one. Tested directly rather than through the span, because the two differ exactly where it matters — a marker with nothing but a suffix after it has no span at all, yet the join would still have absorbed it (`Berg, abdul née PhD` read given 'abdul née').

  What sharing `_maiden_span` between the count and the marker pass buys is one definition of what a maiden span IS. It does not make the two agree about what leaves: they run at different times on different piece lists, and the join between them could invalidate the span the count was taken against. Forbidding the join to absorb a marker is what closes that, not the sharing — the sharing only keeps the two from disagreeing about the span itself.

  Two consequences. It closes the P5 half of the join-swallow #412 records, so that issue narrows to P3's connective join (closed the same day by the #412 / #417 / #418 entry under M2, which also retires this entry's by-hand exclusion). And where the bound word is also suffix vocabulary, a declining join after a family comma leaves the S2 suffix reading and the name has no given name at all — `Berg, abd née Jones` reads family 'Berg', suffix 'abd', maiden 'Jones', which is exactly how `Berg, abd` alone has always parsed. `abd` is the only shipped word in both sets.

  Two alternatives to the exclusion, recorded so "reorder" is not read as one option with one price. The rejected one moves the maiden handler ahead of the PARTICLE CHAIN, which changes what the consumer receives for every name — that is the scope objection, and it stands. A narrower one was not weighed at the time: move the marker pass to run after P2/P3's joins but BEFORE P5, inside _group_segment rather than in group(). Under that arrangement the reserve counts the surviving name directly, so the span prediction, the exclusion and the marker guard all disappear, and P2/P3 are untouched. Its real cost is ordering against the tail-segment delimiter-core drop, which today runs between the joins and the marker pass and would be reversed for comma tails — unmeasured, which is why this records the option rather than taking it.

  No corpus name produces a #411-attributable diff — every baseline already reads these names the way the fix does — so the ledger rule records the side effect on `van der Berg, abdul née Jones` rather than the defect. That name became an M2 example in the #399 fix and reached the corpus when #414's rules-doc corpus harvested it the following day.
  What made it look impossible: adding the word alone changed nothing on the common three-word shape. The reserve count in the join gate excluded suffix-vocabulary pieces, and `abd` IS one, so its own piece did not count toward the three it needed — the rule declined silently. Counting the claimed piece as a name piece fixes it, and is right on its own terms: the reserve asks whether enough OTHER words are left to spare. Measured, the vocabulary-only alternatives both cost something the count fix does not — moving `abd` into SUFFIX_ACRONYMS_AMBIGUOUS loses "Jane Smith, ABD", and dropping it from SUFFIX_ACRONYMS loses the postnominal entirely.
  Two limits, both measured and both accepted. The three-token "abd al rahman" still does not join — `al` is a particle and chains forward, which is the half of the original exclusion that survives. And position decides at the two ENDS only: in the given slot of a family-comma name the credential still wins, so "Smith, Abd" reports suffix 'Abd' with no given name where "Smith, Abdul" reports the given name, and no ambiguity is emitted for it.
  Same shape as #397 (Catalan `i`, where dual membership shrinks the rootname count P3's carve-out tests), and the second instance the SAME DAY, #397 having been filed two and a half hours earlier. The general question — whether a word's membership in one set should suppress it from a count gating a rule keyed on another — is open there.

- 2026-08-21 #418 (PR #420) — #411's by-hand exclusion of the departing words is gone, subsumed by the marker pass running before the reserve is counted (decisions.md#M2, 2026-08-21). Its other half stays: the join still declines when the piece it would absorb is a lone marker, because the only marker left by then is one the consumer declined, and P5 says a marker is not a name word — measured, removing the test re-read `Berg, abdul née PhD` as given 'abdul née'. "Lone" is load-bearing and review is what made it explicit: a declined marker the particle chain has already taken is a word of the chain, and the join takes the chain — `Abd van der Berg née Jr Jones` reads given 'Abd van der Berg née', where the old chain stop had kept the marker outside it. P5's statement now says "standing as a word of its own". The narrower reorder recorded above (marker pass after P2/P3's joins, before P5) was overtaken by the full one ahead of every join, which closes #412 and #418 as well, and the tail-segment ordering it priced as unmeasured was measured: byte-identical under the default policy, a real regression with a suffix delimiter configured, found in review and closed before merge (the M2 entry has the shapes). One row re-pinned: `abd née Jones Jr Smith Berg` reads given 'abd Jr', as `abd Jr Smith Berg` already does — P5 absorbing a suffix piece when the reserve allows is pre-existing and independent of markers, noted rather than fixed here.

- 2026-08-22 #369 — a given-name title licenses the join with one word to spare. #367 regressed `Sheik Abu Bakar` from given 'Abu Bakar' to given 'Abu', family 'Bakar', and its release note recorded why the old reading had been an accident: `abu` is a particle as well as a bound word, and the title used to displace it out of the leading position so the particle CHAIN took 'Bakar'. `Sheik abdul salam` showed that a bound word alone never joined behind a title — the reserve wanted three name words and a title is not one. The question the issue put was whether `Sheik abdul salam` is a two-word given name with no surname, or given 'abdul' plus family 'salam'. Decided on the signal the title already carries: `sheik` is a GIVEN-NAME title, and H1 reads that membership as the assertion that the one word after it is the given name, family empty. P5 now reads it the same way — behind a given-name title the family comma's LENIENT reserve applies, 'abdul salam' joins, and `family=''` is the Accepted outcome H1 records for "Sir John". Not "after any title": `Dr. abdul salam` keeps given 'abdul', family 'salam', because `dr` addresses by family and the reserve exists for exactly that word. Keyed on the whole title run through `_title_key`, exactly as post_rules keys H1, so the two rules cannot read one run two ways: 'mr sir' is not a given-name title to either. Only STRICT relaxes — the family comma's DISABLED segment has no given name to join, and its post-comma segment is LENIENT already.

  Measured at all three baselines: `Sheik abdul salam` moves (given, family), and with it the `Sheik abdul salam Jr` rules example — the same move with the suffix standing — and no other corpus name's fields do; `Sheik Abu Bakar` returns to the fields it had at 1.4.0, 2.0.0 and 2.1.0 — a restoration, which the ledgers record as a non-event — with one v2-surface change: the PARTICLE_OR_GIVEN report it carried through 2.1 is gone, because the join that takes 'Bakar' is P5's rather than the chain's, and a bound word read as the bound word is not a fork. The Arabic-script spelling comes along: `الشيخ عبد الله` read given 'عبد', family 'الله' and now reads given 'عبد الله', which is the name. The previous #369 ledger rules at all three baselines classified the regression "because the cause is known and recorded, NOT because the reading is wanted"; they are rewritten for the fix, and the 1.4.0 one is what keeps `Sheik abdul salam` explained at all: it landed on the fields-only suffix-routing catch-all before #451 deleted that rule, and with no fields-only rule left in any ledger the name now arrives UNEXPLAINED without it — measured by driving `classify()` over the 1.4.0 ledger minus this rule, which returns None for the name and for the `Sheik abdul salam Jr` example. A stronger reason to keep the rule than the one this entry first gave: the alternative is a red gate, not a mislabel.

  Two things review added before merge. The licence lifts the reserve for two WORDS, so the piece it lets the join take must be a single word: a particle chain is one piece, but it is the family name P2 built, and without that clause `Sir abdul van der Berg` read given 'abdul van der Berg', family '' — where the untitled name keeps family 'van der Berg', and so does `Sir John van der Berg`. H1's own scope is "exactly one name word", and the licence now matches it. `Sheik abdul al Rahman` therefore stays given 'abdul', family 'al Rahman', the same limit the `abd` entry above records for the three-token spelling. `Sir abdul van der Berg` is a rules.md example and so sits in the rules corpus, where it reads byte-identical to every baseline — the gate witnesses the clause; `Sheik abdul al Rahman` is in no corpus, and the group test is its only witness. And P5 now states the precedence `Sheik Abu Bakar` only exercised: a bound word that is also a particle is read as the bound word, the join outranking P4's leading-position reading, with no fork reported. That is the reading `Abu Bakar Salim` has always had — P5's join and P4's "the words stay separate" were never reconciled in writing — and the two rules now point at each other.

  Three more from the PR review. The licence takes a name WORD, not a suffix: lowered to LENIENT, the reserve would let the join absorb a suffix piece — `Sheik abdul Jr Smith` read given 'abdul Jr' — which is the shape #421 records for the post-comma LENIENT path and which the STRICT reserve had kept away from titled names; the licence now declines a suffix piece on its own, and #421 will make that decline general. One shape it cannot decline belongs to #401: `Sir abdul V` reads given 'abdul V', because the reserve's initial veto counts a bare `V` as a name word, and the lowered threshold now lets that count fire where STRICT did not — recorded in #401 rather than fixed here. And the join is order-blind, so under a family-first order the licensed pair lands in the FAMILY with no given name: `Sheik abdul salam` reads family 'abdul salam' under both `FAMILY_FIRST` and `FAMILY_FIRST_GIVEN_LAST` (was family 'abdul', given 'salam'), as `Sir John` reads family 'John' there — H1's exemption never runs under a family-first order, so the two rules still agree. Accepted rather than scoped to the given-first read: the effective order is settled in assign, after group, and the outcome is the one H1 already gives the same shape. Two corpus names move under each family-first order, `Sheik abdul salam` and `Sheik Abu Bakar`, measured by hand — the gate reads the default order only.

- 2026-08-22 #401 / #421 — the reserve and the join ask the same question, and it is the one assign answers. Two defects, one cause: P5 says the join "needs a name word to spare" and joins "the word after it", and neither site knew what a name word was. The reserve counted the pieces that were not suffix pieces, and `_is_suffix_piece` vetoes on the initial tag — right for assign's middle-initial question, wrong for the reserve's: a bare `V` carries both `vocab:suffix` and `initial`, so `abdul Smith V` counted three name words, joined, and assign then read the `V` as the suffix it always was — given 'abdul Smith', family '' (#401; shipped since 1.x — 1.4.0 read first 'abdul Smith', last 'V' — every bound word, only the single-letter numerals V, I, X). The join never asked at all: with a word to spare it took a suffix piece as "the word after" — `abdul Jr Smith Berg` read given 'abdul Jr' (#421; 1.4.0 parity) — and where the suffix was the split credential, merge() unioned the piece tags, the joined piece became a suffix piece, and assign sent the bound word to the suffix field: `abdul Ph. D. Smith Berg` read suffix 'abdul Ph. D.', a 2.0 regression (v1's fix_phd extracted the credential before parsing, so position never mattered).

  Fixed with one predicate, `reads_as_suffix`: a suffix piece, or the trailing roman numeral assign's fork takes — and that fork is now `_vocab.is_trailing_numeral_suffix`, lifted out of assign so the two stages share its definition instead of the reserve carrying a copy (the `_ROMAN` pattern moved with it; the regex-sync pin follows it). The reserve counts a piece out when it reads as a suffix, and the join declines the piece it would take when it does — the same decline it already made for a marker. The mirror is assign's fork condition for condition: the numeral is the LAST piece and not the first name piece — not "the piece before it is not a title", which the first draft wrote and the design-docs review falsified, `jr` being title vocabulary as well as a suffix, so `abdul Smith Jr V` lost its family exactly as before — and the piece before it is read as the join would leave it, the joined pair's first token being the bound word (`abdul J. V`, the design-docs review's other find, otherwise suppressed the fork for the reserve alone and read given 'abdul J.', family ''; the reserve now declines, and assign, seeing the unjoined pieces, reads given 'abdul', middle 'J.', family 'V', exactly as it reads `John J. V`). Two more the code review found: assign drops the group-flagged credential pieces (the Ph. D. merge) from its walk at any position before the trailing peel, so a numeral can be last in that walk without being the last piece — `abdul Smith V Ph. D.` lost its family until "last" and "the piece before" were read over the pieces assign keeps — pinned at the piece level only, because a trailing split credential is the shape the 1.4.0 ledger's `[[never]]` entry refuses to explain any diff on, and v1's reading of that name carried the #401 family loss; the example stayed out of the rules corpus rather than the never-entry being narrowed for one bound-word spelling; and the post-comma walk has no numeral fork at all (a trailing numeral there is read by a lenient last-of-two rule, or as a middle initial with a third part), so a mirror applied under LENIENT declined `Berg, abdul V` and `Berg, abdul V, jr` against every baseline with nothing classifying it — the numeral branch is scoped to STRICT, and after a family comma the reserve counts suffix pieces alone, as it always did. So `abdul Smith V Jr` still joins and assign reads family 'V', and `abdul V Smith` still joins an initial, which is a name word. What is NOT mirrored: S2's other positional fork, the bare ambiguous acronym. Where the join leaves two pieces no family is lost — `abdul Smith Ma` reads family 'Ma' where `John Smith Ma` reads suffix 'Ma', assign keeping the acronym as the family of a two-piece name — but with a suffix word between, the join leaves three, assign peels the acronym and then the suffix, and the family is gone: `abdul Smith Jr Ma` reads given 'abdul Smith', family '', suffix 'Jr, Ma' where `John Smith Jr Ma` keeps family 'Smith'. Pre-existing and byte-identical before this fix, and the whole of what remains of #401's shape on the main walk: a review sweep of the STRICT path found 132 family-losing shapes, every one ending in a bare ambiguous acronym. Left for #425 rather than widened into here, and framed there as sharing assign's peel over a post-join VIEW rather than mirroring a second fork by hand: with a view the count is the view's length, the remap and the first-piece carve-out fall away, and the other two sites that stop at a suffix with the same initial veto — P2's chain and M2's walk, which take a trailing numeral the same way (#424) — would ask the question once. The walk was also computed once rather than per piece after review measured the per-piece rebuild as quadratic (13x growth for 4x the input against the benchmark's 6x bound, on a shape no benchmark unit led with); `bound_given` is that unit now. Readings: `abdul Smith V` → given 'abdul', family 'Smith', suffix 'V', the SUFFIX_OR_NAME report unchanged; `abdul Jr Smith Berg` → given 'abdul', middle 'Jr Smith', where `John Jr Smith Berg` puts it; `abdul Ph. D. Smith Berg` → given 'abdul', middle 'Smith', suffix 'Ph. D.' — the SUFFIX field is 1.4.0's again, the given is not: v1's fix_phd extracted the credential before parsing and the join then took 'Smith', reading given 'abdul Smith' — which the issue reports correctly, and then mislabels its own proposed reading as "1.4.0's reading restored". The decline is chosen over reaching past the credential for the next name word, because P5 joins "the word after it": a join that skipped a suffix would read a shape no one writes as if the suffix were not there, and the marker decline already sets the precedent that the join takes the word after it or nothing. A judgement, recorded as one; `Berg, abdul Jr Smith` → suffix 'Jr'; and `Sir abdul V`, the shape #369's licence had widened #401 onto, reads suffix 'V'. #369's licence-local suffix clause is retired, subsumed by the general decline. `bound_given_join_sees_only_the_surviving_name` re-pins to given 'abd', middle 'Jr Smith', as the #418 entry anticipated. No differential corpus name has either shape — measured: the gate was byte-identical at all three baselines before the rules.md examples went in — so the examples carry the shapes into the rules corpus, where they move against every baseline and are classified there. The 1.4.0 `fix(#401)` rule lists three fields where the 2.x rules list two, because 1.4.0 did not even read the V as a suffix; a draft with two fields handed the name to the fields-only suffix-routing catch-all, whose capped printout hid it, and this entry briefly called #401 a 2.0 regression on that inert measurement — the design-docs review caught it. EXPLAINED NOTHING is not byte-identical.

- 2026-08-22 #425 — the reserve runs assign's peel over the view the join would leave. #423 had the reserve re-derive assign's trailing peel by hand — the flagged-credential drop, the walk's start, the numeral fork's conditions, the preceding piece remapped to "as the join would leave it" — with each seam held by a pin, and the bare-ambiguous-acronym fork not mirrored at all: `abdul Smith Jr Ma` read given 'abdul Smith', family '', suffix 'Jr, Ma' where `John Smith Jr Ma` keeps family 'Smith', the whole residue of #401's shape on the main walk (every family-losing bound-word shape in the sweep below contains one). The altitude review's framing is taken: the peel is one function, `_peel_trailing` over the list `_peel_walk` builds (the walk's input defined once too, after the /simplify pass found it spelled at three sites), housed in group beside the piece predicates because assign imports those and group cannot import assign; assign calls it and reports what it returns (the numeral fork's piece, the acronym picks, in the order it always reported them — byte-identical over the suite and the three gates); and the reserve builds the VIEW merge() would leave — the pair one piece, its tags the union — and calls the same function. `BoundJoin`'s "the value IS the threshold" trick survives restated as post-join name pieces: STRICT 2, LENIENT 1. The remap and the `k == first_name_k` carve-out are gone, the joined piece being two tokens and never a suffix piece.

  One principle came out of writing the ledger that the view alone did not state. `abdul Smith Ma` had read given 'abdul Smith', family 'Ma' since 2.0, and the #423 entry called that the lossless shape; the 1.4.0 gate's catch-all count said otherwise — 1.4.0 reads first 'abdul', last 'Smith', suffix 'Ma', as `John Smith Ma` reads, and the fields-only catch-all had absorbed the diff since the example went in (the count is the tell, per the #401 entry). Unjoined, the acronym has words to spare and peels as a credential; joined, the view is two pieces and the fork keeps it as the family — the join was CHANGING what assign reads. So the rule is stated as P5 now has it: the join joins two name words into one and changes nothing else, which the code asks directly: the pieces the peel takes are the same either way (a count proxy, "the view leaves exactly one name word fewer", was the first spelling; the altitude review pointed out it held only because the peel is a tail walk, and the comparison states the rule). That comparison subsumes the unjoined-piece check #423 added for `abdul V` (unjoined the peel takes the V, joined nothing) and restores `abdul Smith Ma` to 1.4.0's reading; `abdul Smith Berg Ma` keeps its join, the acronym peeling either way. Every #423 pin holds. Measured: the 1.4.0 gate is byte-identical at the code change, before the examples went in — the three `Ma`-final examples are v1 parity, the catch-all back at 15 — and `abdul Smith Jr Ma` and `abdul Smith Ma` move against 2.0.0 and 2.1.0, classified there; no hand corpus has the shapes. The population the corpus cannot see, from the design-docs review's sweep of 67,860 constructed main-walk shapes (three bound words — abdul, abd, abu — × twelve words — Smith, Berg, Ed, J., Jr, M.A., Ma, PhD, V, X, 'Ph. D.', 'van der', none a title word — × tails of one to four; no comma shapes; a different twelve-word pool gives different counts, so the pool is the claim): before this change 2,160 lost the family and every one contained a bare ambiguous acronym (864 with further suffixes behind it, so "ending in" was an artefact of #423's shorter sweep); after it none does, and all 67,860 match the family and suffix their ordinary-given twin reads. 5,166 move against master in all — 3,006 where the acronym was the family and is now the suffix (`Sir abdul Smith Ma`), 2,160 where the family is restored. The title-word class below was outside that population and moves after a family comma too (`Berg, abdul Sir`); so was the licence class at the end of this entry.

  Three things the principle does not reach, recorded so nobody applies it there. The post-comma LENIENT path is untouched, and NOT for the reason first written here ("its walk has no peel" — it reads a trailing numeral by the lenient last-of-two rule, as the #423 entry says): the family is fixed there and the pair is the given whatever follows, so the principle is not applied, and `Berg, abdul V` keeps given 'abdul V' though the V would be the suffix unjoined — 1.4.0 parity, and exactly the regression #423 measured when a mirror was applied there; P5 now carries it as Accepted. The vocabulary decline is the UNAMBIGUOUS vocabulary: a bare ambiguous acronym the peel does not take is a name word, so `abdul Ma Smith` reads given 'abdul Ma' (1.4.0 read middle 'Ma' — a 2.x difference no corpus witnesses, now an Accepted example). And the principle is about the suffix reading, not the report: `Sheik abdul Ma` reports no SUFFIX_OR_NAME where `Sheik John Ma` does, the #369 precedent — a bound word read as the bound word is not a fork. One more class moves, found by the code review and then measured against 1.4.0: the 2.x reserve had excluded TITLE pieces from its count — an unrecorded deviation from v1, whose join counted suffixes out and nothing else — and the shared peel, following assign, reads a mid-name title word as the name word it is. `Berg, abdul Sir` (given 'abdul', middle 'Sir' since 2.0) and `abdul Smith Sir` (declined since 2.0) read given 'abdul Sir' and given 'abdul Smith', family 'Sir' — 1.4.0's readings, both; `abdul Sir Smith Berg` never stopped reading given 'abdul Sir'. No corpus name has the shapes that move, and no rules example carries one — `abdul Sir Smith Berg`, P5's example of the reading, is byte-identical at every baseline, so the class is pinned in tests alone (the release log says so; the late review of this entry found the sentence here implying otherwise). The same review found the clause had taken `do`, `freiherr` and `st` from P6 — recorded under P6. What the review actually caught was the tag artefact that exclusion had been hiding: the conjunction merge derives a `title` piece tag for a chain such as "Sheikh and Ahmad", merge()'s union handed it to the pair, and assign peeled the pair as a leading title — `abdul Sheikh and Ahmad Bakar Smith` read title 'abdul Sheikh and Ahmad' on 2.0 and 2.1 (v1 read it as the given), and `abdul Sheikh and Ahmad Bakar`, which the exclusion had kept unjoined, would have too once it went. The bound join drops the tag, and the view drops it the same way — the pair is a given name whatever tag the word carried. One more class the comparison moves, found by the code review's classification of every residual master-to-branch diff (12 of 17,640 constructed names — a fourteen-word pool, bare and behind `Sir`, tails of two and three, 6 × (14² + 14³); the review did not record the pool — one shape; 360 of 360 over {Sir, Sheikh, Dame, Sheik, King, Father} × {abdul, abd, abu, umm} × {J., V, X, A., I} × {V, X, I}): behind a given-name title, a numeral after an initial-shaped piece. `Sir abdul J. V` read given 'abdul J.', suffix 'V' since #369 — the fork suppressed unjoined, fired joined, and the licence's threshold of one let the join through — and the comparison declines it: given 'abdul', middle 'J.', family 'V', exactly as `Sir John J. V`. Pinned at both levels; it was the only shape a looser comparison let through under mutation at the test review. #424 — P2's chain and M2's walk taking a trailing numeral — is the remaining asker of the same question and now has `_peel_trailing` to ask it of.

Excluded (BOUND_GIVEN_NAMES):

- mohamad — a standalone given name in its own right; binding it would eat the middle name.
- abd — ADDED 2026-08-18, see the entry above; the exclusion stood on two reasons and only one of them survived. The collision with the post-nominal "ABD" turned out to cost nothing (position decides at both ends). The other reason stands as a known limit: the three-token spelling "abd al rahman" is still not joined, `al` being a particle that chains forward, so only the two-token "abd X" and the hyphenated "abd al-Rahman X" join.

Excluded (DEFAULT_NICKNAME_DELIMITERS):

- Curly single quotes ('‘','’') are deliberately absent from the default pairs: U+2019 is the typographic apostrophe (O'Connor in curly type), so shipping the pair would eat real names. #273's own proposal excluded them; pinned by the curly_apostrophe_stays_literal case. A sweeper "completing the typographic set" ships the regression.

Excluded (given-name titles and post-nominals — the 2026-07-19 transliteration deferrals, each living in a data-module comment until now):

- Arabic bare سيد/شيخ/أمير/سلطان — given-name collisions (Sayyid, Shaikha, Amir, Sultan); the honorific forms with the article (الدكتور, الشيخ) ship instead.
- The abbreviation د. — edge-period normalization leaves bare د, the single-letter trap; Greek bare κ deferred on the same grounds.
- Ottoman post-nominals باشا/بك/أفندي — surname collisions.
- Hebrew bare רב (an ordinary word, "many") and בר (Bar is a common modern Israeli given name) — deferred, #269's territory.
- Latin sri/shri deliberately absent while Devanagari श्री ships: the transliteration collides (Sri Mulyani), the native script cannot.

Excluded (multi-word vocabulary entries — a set outside #M2's two exceptions matches one written word, so a multi-word entry there is silently inert and warns at configuration; given_name_titles and, since #434, maiden_markers are the exceptions, and they match phrases by different mechanisms):

- Eight entries shipped unmatchable from 2013 to 2.0. chargé d'affaires was SPLIT into the chainable chargé + d'affaires, plus unaccented charge (the attaché/attache precedent, Derek's call); leed ap, nicet i–iv and psm i/ii were REMOVED rather than split, on the name-swallowing measurements recorded in #291 (see the comma-suffix-arc Declined entry). The multi-word UserWarning is this story's enforcement.

Excluded (Lexicon.honorific_tails — a glued tail peels only if it could never end a name; per-entry reasons live in nameparser/config/suffixes.py's vetting block):

- 양, 군 — 김지양 and 김지군 are given names ending in these syllables, and 양 is a top-tier surname (Yang) besides. (The surname-LEADS argument is a different job: it is why both are safe in the SPACED set — a leading surname never meets the trailing-only suffix gate, so 양 미선 keeps family 양.)
- 氏 — 王氏 is a historical name form ("the Wang woman").
- 博士 — 田中博士 is Tanaka Hiroshi as readily as Doctor Tanaka.
- 殿 — Japanese surnames end in it (鵜殿, 真殿, four-figure populations); peeling it would cut a real family name in two.
- 君 — 王君 is a complete Chinese name; its kana spelling くん does peel.
- जी (standing prohibition for #344's implementation) — Banerjee, Mukherjee and Chatterjee end in the substring (बनर्जी/मुखर्जी/चटर्जी), and glued peeling strands a fragment on a bare virama (बनर् + जी). The 殿 criterion, in a non-CJK script — which also shows the criterion is not CJK-specific.

Excluded (TITLES):

- ঠাকুর — a genuine Bengali honorific (lord/master) that is also Tagore, the surname (#343 records it so a wordlist sweep does not ship it).
- The trailing-position rule that must NOT be adopted: TITLES holds hundreds of words in no suffix set, at least nineteen of them ordinary English surnames (king, judge, bishop, baron, sheriff, ...), so a blanket "vocabulary outranks position in the trailing slot" reading would turn "Mary Jane King" into title="King" with the family name gone. The leading half of this argument is AGENTS.md's "Dean is deliberately absent" gotcha; this is the trailing half, and it shadows the family name rather than the given (#316).

Excluded (Policy.script_orders defaults): Script.KATAKANA is deliberately absent — a pure-katakana token is predominantly a transcribed foreign name kept in its source order, so nothing defaults on it (rule W4's boundary). Noted 2026-08-15: of the three Script-keyed axes, this is the one with no force-a-decision guard (mechanisms.md#FORCE-A-DECISION-TABLE), so a new Script member silently gets no order.

Excluded (MAIDEN_MARKERS, per nameparser/config/maiden_markers.py):

- Polish "z domu" — a two-token marker; pending the multi-token matching decision, tracked in #291 since 2026-07-27.
- Contrast entry — unaccented "nee" SHIPS as a marker despite
  #274 flagging "is nee safe as a default (it's also a rare
  surname)" as open; the question resolved silently with the shipped set. Recorded here because the included risky member deserves its analysis as much as the excluded ones; M1's (Nee) boundary covers only the enclosure path, not this marker path.
- "born" — never shipped: a release-log drafting invention, caught by the 2.0 milestone audit and corrected (5ccf9f3). Recorded so nobody "restores" it; if ever proposed for real, Max Born is the counterexample to analyze.
- Scandinavian "f." — collides with the initial F.; only the full participles (født/fødd/född) are safe. Czech masculine "rozený" awaits the same vetting, on its own merits rather than following the feminine form in.
- Polish "z domu" — INCLUDED 2026-08-26 as the set's first phrase entry, and the contrast to draw against the roz removal below rather than against any included word. Its words fail C-i separately and it passes as a pair; the argument is in the #434 entry under #M2. The point to carry away is that the entry below could not have been rescued the same way: roz is a whole marker as written, so there is no longer unit to move the claim onto.
- Czech/Slovak "roz." — SHIPPED through 2.1 and removed 2026-08-26 (Derek's call). Roz is an ordinary English diminutive of Rosalind, and matching is whole-token, case-folded and period-insensitive, so "Roz", "roz" and "roz." are one string to this set. The collision is not theoretical and it is not new: on the bare path, which predates the 2.2 work entirely, "Rosalind Roz Smith" read maiden "Smith" and NO family name at all, and "Rosalind Roz Jones Smith" read maiden "Jones Smith", because M2 hands the marker every word after it. Grounded in #vocabulary-collisions C-i — a word belongs in its set's ambiguous subset iff it is borne as an ordinary name IN THE POSITION THE VOCABULARY CLAIM ACTS ON, and under uncertainty default to ambiguous. Roz is borne exactly there: the word after a given name, which is the only position M2's claim acts on. What C-i cannot do for this set is the marking: MAIDEN_MARKERS has no ambiguous subset, and no code path consults one, so the only two expressions of C-i available here are ship and do not ship. That is why the remedy is removal rather than an ambiguous marking, and it is worth knowing before someone proposes marking the next collision instead. The full participle rozená stays and is unambiguous; a Czech caller who needs the abbreviation adds it to their own Lexicon, which is the same answer this section gives for every locale-specific vocabulary. The cost is accepted and stated: "Anna Nováková roz. Svobodová" now reads middle "Nováková roz.", family "Svobodová" — which is how 1.4.0 read it — and "Rosalind Roz Smith" is back to 1.4.0's given/middle/family. Pinned by tests/v2/cases.py::diminutive_that_was_a_marker_keeps_the_family and ::full_participle_marker_still_consumes.


### C1 — the suffix-comma decision

- 2026-07 (v2 core, PR #288) — v1 parity: only the second segment decides (v1 parser.py:1318), and ">1 word before the first comma" is v1's guard, which is why "Smith, PhD" keeps the listing form. The strict/lenient knob's blast radius on default vocabulary is exactly the single-letter Roman numerals i and v — the only initial-shaped members of the shipped suffix words — which is what makes rules.md#C1's "V." examples the canonical pair.
- 2026-07 (plan deviation #3, recorded) — the decision is definitionally vocabulary-dependent: there is no way to recognize a credential run without consulting the suffix word lists, so the structural stage reads vocabulary through one predicate.
- 2026-07-12/13 (v2 Policy work, PR #288) — the lenient token test is the default and `lenient_comma_suffixes=False` restores the strict one. (An earlier entry here credited #291/#296 — git author dates place both the field and its wiring in the Policy commits, and the #291/#296 design doc never mentions the knob.)
- 2026-08 #319 — the wholly-suffix predicate was lifted into the vocabulary layer so the comma decision and the honorific peel's segment test cannot drift apart.
- 2026-08-23 #296/#325 — the listing form's post-comma part is read for what it is. Three commits of the approved #291/#296 bundle (spec 2026-07-30, plan 2026-08-01; see the comma-suffix arc below), in the plan's order. (1) A comma followed only by titles fixed no family boundary, so the pre-comma name keeps its positional read: `John Smith, Mr.` reads given 'John', family 'Smith', title 'Mr.' where 2.0 had merged the family ('Bob Jones, author' moves with it at every baseline). "Nothing but titles" is title-shaped AND not suffix vocabulary — the plan's helper said only the first, and would have read `Smith, PhD Jr.` as the title run 'PhD Jr.' and `Smith, Mr. Jr.` as 'Mr. Jr.' (both master's suffix 'Jr.'; the gate and a measurement against master caught them). (2) The TITLES/suffix overlap audit and the ordering, one commit because each alone is wrong (the plan's F1): reading the credential run first would make `Smith, Dr.` a suffix while 'dr' was SUFFIX_WORDS residue, and the audit alone leaves `Smith, Jr.` a title by the period-abbreviation inference. The audit table's dispositions ship as approved — 'do', 'jr', 'junior', 'phd', 'se' out of TITLES; 'dr' and 'sra' out of the suffix sets; 'ms' and 'sa' into the ambiguous set — with ONE deviation on evidence the table did not have: 'md' keeps dual membership, because bare 'Md' before a name is the Bengali and South Asian Muslim abbreviation of Muhammad (#343/#345's corpus rows `Md Abdul Karim`, `Md. Abdul Karim`; the drop read the first as given 'Md', middle 'Abdul') while 'MD' after the name is the degree — position decides, as for 'sr'. The ordering is wider than the plan's lone piece: a post-comma part that is nothing but suffix pieces is the credential run, whole, which is what #325 needed (`Smith, Ph. D. Jr.` had put the split credential in the given name once a second suffix made it no longer lone — a 1.4.0 regression; v1 read suffix 'Ph. D.', title 'Jr.'). Vocabulary decides which words qualify and position breaks the tie for the genuine duals: `Smith, Sr.` is Senior where `Sr. Garcia` is Señor, `Smith, Esq.` is the postnominal where H2 still reads `Esq. Smith` as a title (H2 carries that as Accepted). `Smith, Dr. Jr.` keeps the 2.0 deviation's other case — a title, then the last piece as the suffix (v1 read first 'Jr.'; 2.0 through master suffix 'Jr.') — and `Smith, John Jr.` its given name. Consequences the audit carries, all measured and pinned: a leading 'PhD' or 'Jr' is a name word; `Do Quang Minh` reads given 'Do' with the particle-or-given fork, and `Dr. Do Van Johnson, MD` given 'Do', family 'Van Johnson' (the #367 rule for that name is replaced; TITLES ∩ ambiguous is {freiherr, st} now, which keeps the chained emitter reachable); a trailing bare 'Dr' is a name word, joining every other title-only word there (#316 is the open question); `田中さん, PhD` reads suffix 'さん, PhD'; the #100 prefix-join tests use 'lt', a genuine dual, as their token. #291 (multi-word credentials) is the bundle's fourth commit and its own PR.
- 2026-08-23 (review of PR #428, four reviewers) — the repair's condition is "no name word", not "all titles": `John Smith, Mr. Jr.` (a title and a postnominal) had kept the merge the rationale argues against, and reads given 'John', family 'Smith', suffix 'Jr.' now. Its guard counts NAME pieces: the positional read peels a trailing suffix first, so `Smith Jr., Mr.` — two pieces, one name — read positionally lost its family (the code review); it keeps family 'Smith', suffix 'Jr.' at every baseline. The positional read records its ORDER, and post_rules' leading-piece scan keys on "assign records no order after a family comma" rather than on the structure, so P1's family-first fold reaches the pre-comma name as it does without the comma — `de Mesnil Jean, Dr.` under a family-first order had read family 'de' (the test review). The credential run does NOT step over a delimiter core the policy names (`Smith, RN - CRNA` reads given 'RN' under `extra_suffix_delimiters` as without it): the code review asked for it, a draft did it, and tests/test_suffixes.py's "the delimiter must not affect parsing at all" outside the suffix-comma form is v1 parity pinned on purpose — C1 carries the limit as Accepted. A second deviation from the audit table: `ms` and `sa` do NOT join the ambiguous set — the gate is position-blind and the collision is not; gated, `John Smith, MS` lost its suffix-comma route and read title 'MS', and `Smith, Ms.` passed the gate on its one period and read as a credential anyway. Both are genuine duals, and position decides: `Smith, Ms.` reads suffix 'Ms.' (title at every baseline; C1 carries it as Accepted, with `Smith, Ms. Jane` keeping the title) and `John Smith, MS` keeps suffix 'MS'. And the gate itself now means what S2 says: an ambiguous acronym counts when written with ITS periods, one after each letter (`M.A.`, `J.D.`), not on any period — `Smith, Ed.` had become a credential on a name's trailing period, and `Jack Ma.` had read suffix 'Ma.' since 2.0 (S2 carries the boundary; 1.4.0 read family 'Ma.'). Two limits recorded rather than fixed: `田中さん, Dr.` keeps the honorific glued, joining master's `田中さん, Mr.` — the honorific peel runs in script_segment on the suffix-comma and no-comma structures, before group or assign can say the comma fixed nothing (C1 Accepted); and group's chain emitter still reports no PARTICLE_OR_GIVEN on the family-comma path, so `Freiherr von Berg Ma, Mr.` reads as its no-comma twin but without the report — group runs before assign decides which read applies, and plumbing a deferred report through the state is more than the shape is worth today.
- 2026-08-23 #429 — one comma segment is one suffix entry, and the two stages disagreed about which segments those are. group asked by segment INDEX (`tail_start`: 1 after a suffix comma, 2 after a family comma), which is right wherever assign reads the segment as suffixes for the same structural reason — but not after a family comma, where segment 1 is a name slot assign re-reads by CONTENT with `_segment_holds_no_name` and routes piece by piece (#296/#325). So the roles were right and the render was not: `Smith, MD PhD` gave suffix 'MD, PhD' with a comma the writer never typed, and `Smith, CBE MC`, `Smith, BSc MBA` and `Smith, PhD Jr.` the same, while the full-name `John Smith, MD PhD` has rendered 'MD PhD' since 1.4.0. The predicate moved down into group beside the piece predicates it already reads — the same direction `_leading_titles` moved in #424, and the only direction available, since assign imports group.
  SCOPE, stated as the condition actually reads rather than as the first draft described it: any family comma whose following segment holds no name word, NOT only a one-word family. `John Smith, Jr. III` renders suffix 'Jr. III' (1.4.0's reading), and a title-led segment is in too — `Smith, Dr. MD PhD` renders 'MD PhD'. The differential cannot witness the widening: of 1069 corpus names, 303 carry a structure comma, the predicate is true for 36, only 9 of those have two or more pieces in the segment, and none of the 9 combines a multi-word pre-comma name with two suffix pieces. 0 unexplained is therefore evidence about the corpus, not about the class — the case table carries it instead.
  THE REGRESSION THE REVIEW FOUND, and the reason this entry is long: the first draft let ANY piece open an entry, as the tail block always had. That is safe on a tail segment, where assign routes every piece to SUFFIX — which is what `tail` means — and wrong off it, where a title piece routes to TITLE. Two failures, one cause. The `joined` tag is role-BLIND and the facade heals it for every role, so a title opening an entry collapsed `title_list` from ['Rev.', 'Dr.'] to ['Rev. Dr.'] on `Smith, Rev. Dr.`; and where the pre-comma name left a suffix, the tag glued the next suffix BACKWARD across the writer's own comma — `Smith Jr., Mr. Jr.` rendered suffix 'Jr. Jr.', the exact inverse of the bug being fixed. Neither is visible to the differential, which compares strings, nor to the case table as it stood, which asserts the title STRING (space-joined either way). The rule that replaces it separates two joins that had been one: WITHIN a piece the tag renders a merged piece as one unit, whatever role it holds; BETWEEN pieces it continues an entry, and only a piece that renders into the same run may do that. The entry is sticky across a piece that is not in it, so an interleaved title does not split the run it sits in (`Smith, MD Dr. PhD` renders 'MD PhD'); a delimiter core still closes it, which is the one thing that separates two entries.
  Scoped deliberately, and the scoping is the other decision: `tail` still gates delimiter-core dropping, which is v1 `expand_suffix_delimiter` parity (#206, commit 021823e — three code comments say #191, which is the German/Dutch vocabulary PR, and are corrected here) and a TAIL rule. The core set is threaded to the block as `seg_cores` so the widened condition cannot drop cores where `tail` says not to. Worth knowing before anyone tries to pin that: for every realistic delimiter the two spellings are indistinguishable by construction, because the core must first pass the very predicate that gates the block — the only reachable class is a policy whose delimiter core is itself suffix or title vocabulary (`Policy(extra_suffix_delimiters={" jr "})`, `Smith, MD jr`), which the case table does not enshrine.
  Cost, measured rather than optimised away: `_segment_holds_no_name` now runs TWICE per family-comma parse, once here and once in assign, on the same pieces — verified pure recomputation, not two questions (408 of 506 comma-bearing test literals reach both sites, 0 disagreements, and off-tail `seg_cores` is empty so `kept` never filters, which is why assign sees exactly the pieces group tested). Removing the second call is worth 0.6–1.4 µs, 1.2–2.2% of a family-comma parse and 0% of every other parse (NO_COMMA and SUFFIX_COMMA make no call at all). Caching it needs a derived ParseState field the two sites would otherwise not share, so the number is recorded instead. The order is cheapest-first per 7009fcf: two bool loads and an int compare gate the call.
  R1 gained the separator sentence with this change. That the invariant it states — one comma segment is one suffix entry — was written down in no rule is a large part of why two stages disagreed about it for years: C1 governs routing, R1 governed word ORDER and never mentioned separation, and the only statement of the rule was a code comment.
  Left alone, measured and pre-existing: the NO-COMMA path comma-joins a space-separated run too (`John Smith MD PhD` renders 'MD, PhD' at 1.4.0, 2.0.0, 2.1.0 and master alike). The fix requires FAMILY_COMMA and does not reach it; that path has no writer-drawn boundary and is a separate question, recorded here and NOT yet filed. It is load-bearing for one thing: `str()` of a fixed parse is a no-comma string, so re-parsing it re-inserts the comma — round-tripping is not stable on these shapes, and an earlier draft of the release bullet claimed it was.
  The name `Smith, MD PhD` entered the corpora as this fix's C1 example — a FIRING one, carrying no `· boundary` annotation, which marks the non-firing example a rule must have — so its ledger entries arrive with it. A fourth written for the 1.4.0 ledger EXPLAINED NOTHING and was dropped: `fix(comma-family) lone post-comma piece routes to suffix/title, not first` already claims the name there, its fields covering all three that move. That is #373's dormant-rule diagnosis doing its job, and #426 is the precedent for dropping rather than keeping a shadowed rule.
- 2026-08-25 #430 / #432 — one boundary, fixed as one: inside a comma segment a ONE-CHARACTER suffix word — the only kind a reader could take for an initial — is read by WHAT STANDS BEFORE IT. Behind another suffix it describes that suffix and continues the run, period included — `Smith, PSM I` and `Smith, PSM I.` are suffix 'PSM I'/'PSM I.' where they read given 'PSM' plus suffix 'I', an initial in that slot being no shape anyone writes. Behind a name word the period is decisive, marking an abbreviation and so name material — `Smith, John V.` is middle 'V.' where it read suffix 'V.', which is what `Smith, John B.` always did; the only thing that made V differ is that V is also suffix vocabulary. The bare form is the boundary and does not move: `Smith, John V` stays suffix 'V' (v1 parity, #144), which is why the test is the PERIOD and not the numeral. SCOPE, stated as the code reads rather than as the intent was argued: the class is one-character SUFFIX VOCABULARY, not "a roman numeral". It sweeps in `2` (`Smith, John 2.` is middle '2.'), misses `X` and `IX` (roman, but not vocabulary — `Smith, PSM X` is unchanged), and never touched `II`/`III`/`IV` at all, which are two characters and so were never initial-shaped and never vetoed. Anyone adding a one-character suffix in a script that has initials extends it. And the strict knob is consulted: under `lenient_comma_suffixes=False` the initial veto stands and the run ends at the numeral, which the first draft silently overrode by reading no policy at all.
  #430's cause was rules.md#S2's initial veto in `is_suffix_piece`, which keeps a numeral out of a suffix reading — right after a name, wrong inside a credential run — so the segment did not look like a run at all and fell to the given-and-suffix walk. Two wrong answers came out of that one cause, and a fix verified on `Smith, PSM I` alone would have left the other: where the run's first word is TITLES vocabulary too (`Smith, MD I`, and `Smith, Jr. I` through the period-abbreviation inference) the leading-title peel took it instead, giving title 'MD' plus given 'I'.
  THREE readers, one reading, which is the part worth copying. The gate that says a segment is a credential run, assign's router that assigns its roles, and group's one-entry join that renders it must agree PIECE FOR PIECE, and #429 shipped the inverse of its own fix by deriving that agreement twice. `segment_suffix_reading` returns the per-piece verdicts and all three read it (mechanisms.md#ONE-PREDICATE-PER-QUESTION); the intermediate state where only the gate and the router had been converted rendered `Smith, PSM I` as 'PSM, I', the render splitting a run whose roles were already right — the same tell as #429, one predicate along.
  Declined, and the v1 suite caught it: asking `is_trailing_numeral_suffix` for #432's half. It answers the period question — `_ROMAN` is anchored and admits no period — but it also refuses a numeral standing behind an initial-shaped piece, which is a NO-COMMA rule and the opposite of this path's parity: `Chang, Andy C I` is first 'Andy', middle 'C', suffix 'I', and asking that predicate here made the numeral a middle. decisions.md's #401/#421 entry records the same fork declining to transfer to this walk under LENIENT. The half that applies is the period alone.
  Blast radius, and the corpus IS blind to the moving classes: of the 1069 names on master, seven carry a one-character numeral behind a word inside a comma segment — `Chang, Andy C I`, `Doe, Rev. John V, Jr.`, `Maier, Amy I, Jr.`, `Maier, Amy Lauren I`, `Smith, John I`, `Smith, John V`, `Berg, abdul V` — every one of them BARE, and NONE moves. Zero pre-existing corpus names move at all; the only movers are the three this change adds as C1 examples. So the gate's 0-unexplained is evidence about the corpus, not about the change, and the case table is the whole coverage. `Berg, abdul V` cannot reach the walk at all: the bound-given join makes `abdul V` one piece, which is why the LENIENT mirror #401/#421 declined does not collide here. The moved names entered the corpora as C1's new examples, and carry ledger entries at all three baselines except #430's at 1.4.0, where `fix(comma-family) lone post-comma piece routes to suffix/title, not first` already claims the name and a second rule would be dormant.
  Two things the /simplify round settled by measurement rather than argument. The walk asks `is_suffix_piece(pieces[m - 1])` — a VOCABULARY test — where the reading carries the verdict it just recorded, and unifying them looks obviously right until you run it: the walk assigns `PSM` to GIVEN in `Smith, PSM I.`, so a reading-style flag says "behind a name" and sends the numeral to middle, losing the strict parity restored two commits earlier. The two sites ask different questions on purpose, and the vocabulary answer is the load-bearing one. The cost is a reading the walk cannot reach: `Smith, John PSM I I.` moves from middle 'I' + suffix 'PSM, I.' to middle 'I I.' + suffix 'PSM', two consecutive one-character numerals behind a credential — accepted, both readings being defensible for a shape nobody writes, and recorded here rather than chased at the price of two correct pins.
  The walk is also strict-blind, pre-existing and unchanged: it calls is_suffix_lenient unconditionally, so `Smith, John V` reads suffix 'V' under `lenient_comma_suffixes=False` as under the default. The reading consults the knob and the walk does not, which is now conspicuous where it was merely unexamined. Not this change's to fix.
  Not done, and recorded rather than left implicit: neither half emits an ambiguity, though the no-comma `John Smith V` reports `suffix-or-name` for the same fork. The comma path has never reported it (decisions.md#C1 records PARTICLE_OR_GIVEN suppressed there for a related reason), and adding an emitter needs a trigger, a stage-ownership entry and a case row — more than this fix, and #432 raises it as a secondary observation rather than a requirement.

### T1 — separators, not joiners

- 2026-07 (v2 core, PR #288) — v1's squash_emoji/squash_bidi REMOVED the character and joined its neighbors. (v1.3.0 had no bidi handling at all: squash_bidi entered late v1 via #266, 2026-07-07, on the emoji precedent's shape.) ('A😀B' → 'AB'); v2 makes an ignorable character a separator ('A😀B' → 'A', 'B'). The unavoidable consequence of every part being an exact positioned piece of the input: with no rewriting stage, nothing can splice two half-words together.

### T3 — the interpunct's flank guard

- 2026-07-30 #298 — codepoint scope, chosen over a blanket
  "dot = transcription" rule that would have flipped 高橋・一郎, a
  correct and deliberately pinned #272 reading: U+00B7 records the transcription fact and suppresses division; U+30FB/U+FF65 stay pure separators whose pieces the script license reads (rule W4's Accepted pair demonstrates both). Cross-convention input reads by the codepoint it was actually typed with — a chosen limitation, recorded in #298's comments.

Declined:

- Ambiguity emission on a half-flanked interpunct ("王·Smith") — proposed in the 2.1 PR review, declined because the dot decides silently under the no-emission decision; the undivided-dot-stays-in-the-word behavior went to docs instead.


- 2026-07-30 #298 — U+00B7 divides only between classified-script characters because it is also the Catalan punt volat, interior to legitimate names (Gal·la). The nakaguro (T2) needs no such guard: its codepoints are CJK-only and appear in no other script's names, which is what licenses an unconditional rule.

### M1 — delimited maiden names

- 2026-07-03 (maiden-bucket design, landed via the v2 core, PR
  #288) — the maiden reading of a delimiter pair is opt-in because
  enclosure conventions genuinely vary; there is no default pair.
- 2026-07-18 (cc7063f; Derek's proposal out of a docs-review pain point — routing parens to maiden used to require editing both buckets in tandem, and the nickname default's contents were not discoverable) — bucket overlap is canonicalized before parsing: a pair listed for maiden is dropped from the effective nickname set (maiden wins), and the public DEFAULT_NICKNAME_DELIMITERS constant landed with it. The v1 facade restores v1's nickname-wins reading by pre-subtracting on its side. Weighed and ruled the same day: maiden-wins applies THROUGH apply_patch too — a pack's maiden pair silently removes a user's unrelated explicit nickname pair; warn-on-removal was considered (a silent-failure review flagged the site) and rejected, the silence ruled intended.
- 2026-08-04 #329 (PR #331) — the marker word inside a delimited clause is dropped from a multi-word clause during grouping; the extraction itself keeps the whole enclosed span, so nothing is lost when there is no marker. Three decisions argued separately: the clause-size guard exists because Nee is an attested surname (Irish Ní/Nee, a Chinese romanization) — load-bearing, not defensive, and mutation-proven; clauses are independent — "(Nee) (Jones)" reads maiden "Nee Jones", each clause dropping or keeping its own marker (the first implementation leaked across clauses, a real defect); and the contentless "(née —)" alone now parses to every field empty and bool() False (an explicit alternative keeping maiden "née —" was rejected; pinned by the maiden_marker_delimited_content_free case).
- M1 and M2 are disjoint by construction, which is the no-conflict guarantee: M2's walk covers joining structure that role-bearing words never enter, while M1's drop reaches only extracted content. Verified independently at review over 7,775 records: 967 diffs, every one in the maiden field.

Declined:

- Neighbour-scoping the drop (drop a marker whose next word also reads maiden) — implemented, reviewed and rejected: it also fires on the bare-marker path, eating the surname out of "Jane Smith nee Nee Jones" (maiden "Jones" instead of "Nee Jones"), and it leaks across adjacent clauses. It is the obvious implementation, which is why this entry exists.

- 2026-08-05 #329/#335 — marker auto-detection inside a nickname-delimited clause was deferred to #335 on a corpus measurement: 山田 花子（旧姓 佐藤） is in the CJK differential corpus so the #329 change was gate-visible, while "Jane Smith (née Jones)" is in no corpus — shipping auto-detection in 2.1 would have let a real Latin-affecting change ride under a "0 Latin-only" gate report.

- 2026-08-26 #335 — correction to the 2026-08-05 entry above, which reasoned from "the #329 change was gate-visible". It was not. Under the default policy the corpora run, no name reached the drop pass at all, so 山田 花子（旧姓 佐藤） moving between 2.0.0 and 2.1.0 was the East Asian order flip and not #329. Nor is #329 gate-visible now that M3 routes marker-led clauses to maiden without configuration: a ledger rule narrows by which FIELDS move and never by what they hold, so reverting the drop leaves the same six names moving the same fields and all three gates green (measured by mutation 2026-08-26). The {nickname, maiden} pair is the 2.1.0 ledger's; the other two baselines classify the CJK name differently, which is why those ledgers are split and must stay so. The deferral #329 argued for was still the right call — it was about a Latin-affecting change riding under a gate report, and that half held — but the premise is corrected here rather than left to be re-used. tools/differential/README.md carries the same correction beside the table it belongs to.
- 2026-08-26 #335 (M3) — the opt-in this section opens with narrowed, and the boundary is not where the 2026-07-03 entry drew it. A clause whose content announces itself no longer needs its pair declared, so what configuring maiden_delimiters buys is now markerless content and one-word clauses. See #M3 for the whole reasoning; M1 still governs every clause whose pair is configured, and M3 defers to it for the marker drop itself.

- 2026-08-26 #434 — correction to the 2026-08-04 #329 entry above, on the two statements a phrase entry falsified, and a decision that entry's reasoning does not reach. Its "dropped from a multi-word clause" is no longer the condition: "(z domu)" under a configured pair is a two-word clause whose marker is NOT dropped, because both words ARE the marker. The condition has always been "the clause holds a word past its marker" and reads that way in the code; with one-word markers the two were indistinguishable, which is why the entry could say the wrong one for two years without cost. Nor is there a "clause-size guard" any more, load-bearing or otherwise — the containment test now spans the whole marker run, so its subject is the token AFTER the run rather than the second token of the clause. What survives unchanged is the REASON that guard was written: Nee is an attested surname (Irish Ní/Nee, a Chinese romanization), so a clause of nothing but its marker keeps its words, and that argument is mutation-proven still. Recorded here rather than only under #M2, because a future PR reading M1's own history pointer would otherwise find "the clause-size guard" described as load-bearing and restore something that reintroduces the phrase defect while passing every doc test.

  The decision the rewrite forces, stated so a later PR has something to weigh against. Under a configured maiden pair, "Maria Kowalska (z domu)" emits maiden "z domu" — the marker itself as the whole maiden name. That is not what the phrase argument under #M2 says about those words: "z domu" is borne as no name in any position, which is exactly why shipping it as a phrase is safe. So the value is a name nobody has. It is emitted anyway, and deliberately: M1's carve-out is that a clause holding nothing past its marker keeps its WORDS, and the parser has no way to know a caller's configured pair did not enclose an unfamiliar surname — the same ignorance that makes "(Nee)" keep its word. The alternative readings, an empty maiden or a nickname, both require the parser to judge its own vocabulary's words unnameable, which nothing else in the library does. Cheap to revisit: the shape is one rules.md example and one case row away from a decision either way, and no corpus name has it.

### M3 — the marker-led clause

- 2026-08-26 #335 (M3, PR on fix/335-maiden-marked-clause) — a bracketed clause whose content opens with a recognized marker word and carries a word after it reads as the maiden name, whichever bucket the enclosing pair sits in. The parse already had the information: "Jane Smith née Jones" gave maiden "Jones" while "Jane Smith (née Jones)" gave nickname "née Jones", because ( ) is a nickname pair by default and nothing looked inside it. The clause says "maiden" out loud, so the caller should not have to say it in Policy.
- 2026-08-26 #335 — the MECHANISM, and the issue proposed the other one. Masking the delimiters and letting M2's bare-marker rule consume the content would avoid a second implementation, and it was rejected on measurement: the two readings agree on a trailing clause and diverge on an interior one. "Jane (née Jones) Smith" reads given "Jane", family "Smith", maiden "Jones" under extraction; masking turns it into the bare "Jane née Jones Smith", which reads maiden "Jones Smith" and family "" (measured 2026-08-26), because M2's take runs to the end of the name. The closing delimiter is a boundary the writer typed and masking throws it away. Extraction was also the SMALLER change — extract already emits Role.MAIDEN regions and group already drops the marker inside one (#329), so this is a role swap at the existing emit site plus one predicate, in the branch S1 has already declined. That placement is what keeps S1 first, but be exact about the mechanism, because the obvious mutation does not test it: hoisting the swap above the _suffix_shaped call changes nothing at all (measured 2026-08-26), since the S1 branch never reads `role` -- it masks the two delimiter spans and hands the content to the token stream, and the role it would have carried is never consulted. What inverts the precedence is giving M3 the clause first, an early `extracted.append((Role.MAIDEN, inner))` ahead of the S1 test: that reads "Jane Smith (née Jr.)" as family "Smith", maiden "Jr." instead of family "née", suffix "Jr.", and M3's own example line for it fails. So the precedence is pinned, and by that example rather than by the branch shape.
- 2026-08-26 #335 — the condition is a word AFTER the marker, not the marker alone. The reason is M1's: a one-word clause keeps its word because Nee is an attested surname (Irish Ní/Nee, and a Chinese romanization), and a marker with nothing after it marks nothing. Be precise about what pins it, because the obvious answer is wrong: M1's own boundary line carries the maiden-parens annotation, so it asserts the CONFIGURED reading and passes either way — built and measured 2026-08-26, a len(words) > 0 predicate still gives it maiden "Nee". What the loosened predicate actually breaks, measured by mutation 2026-08-26, is M3's own boundary example ("Jane Smith (née)" → nickname "née") and tests/v2/cases.py::maiden_marked_clause_one_word_stays_a_nickname in both runners — three pytest failures — AND the 2.1.0 differential gate, which goes red with three unexplained names: "Jane Smith (Nee)", "Jane Smith (Nee) (Jones)" and "Jane Smith (née)". The gate is the stronger signal and the one to look at first. The middle name is why: loosened, it reads maiden "Nee" and nickname "Jones", splitting one bracket pair into the maiden field and the next into the nickname field, which is the direct contradiction of M1's "two enclosures read as one maiden name". Anyone re-litigating the second-word test should check those, not M1's line. So the real division of labour is NOT markerless versus marker-led, which is how it was first written into rules.md and had to be corrected at review: it is one word versus a marker plus a word. "Jane Smith (Nee)" is marker-led and still reads nickname "Nee" by default, maiden "Nee" only with the pair configured (both measured 2026-08-26).
- 2026-08-26 #335 — accepted consequence: the word taken after the marker is not tested for being a name word, so M3 does not stop at a suffix word where M2's bare take does. "Jane Smith (née V)" reads maiden "V" while the bare "Jane Smith née V" reads suffix "V" and family "née" (both measured). The reason is structural rather than an oversight: M3 hands the WHOLE clause to Role.MAIDEN and the bracket is its right boundary, so the trailing-suffix rule never sees the word as trailing the name at all. Pinned as M3's Accepted line in rules.md, which puts "Jane Smith (née V)" in the differential corpus and classified in all three ledgers.
- 2026-08-26 #335 — rules.md#M3 lists implemented: nameparser/_pipeline/_extract.py alone, and that is deliberate rather than an omission. M3 decides a REGION'S ROLE; the marker drop it describes ("the marker itself dropped, as M1 drops it") is M1's mechanism in _group.py, which cites M1 and is governed by it. Adding _group.py to M3's list would mean inventing an M3 citation there for behavior M1 already owns — and test_doc_citations requires implemented: to equal the set of modules that cite the rule, so the two would have to move together. Do not re-open this.
- 2026-08-26 #335 — the durable finding, and the one worth reading before touching either side: _maiden_marked asks a WHITESPACE-SPLIT, _normalize'd question of the clause's first word, while tokenize and classify ask a token-level one. _normalize strips a trailing period but not a comma, so 'née,' is not a marker to M3 while the tokenizer splits the comma off and still tags the token vocab:maiden-marker. That divergence is the only thing keeping the "role is not Role.MAIDEN" branch of _group.group's clause-scoped drop pass reachable at all: without it, a marker-led NICKNAME clause could no longer exist, since M3 would have converted every one to MAIDEN before group ran. Anyone "unifying" the two tests would orphan that filter silently. The pin is tests/v2/cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname, and it needs BOTH of its clauses — the drop pass is gated on the name holding a maiden region at all, so a marker-glued nickname clause standing alone leaves the branch unexercised (measured by mutation, 2026-08-26). The row that used to pin the filter, maiden_marker_delimited_beside_a_nickname_clause, lost the job to M3: both its clauses are marker-led, so both are maiden now and there is no contrast left in it.
- 2026-08-26 #335 — M3 makes the marker vocabulary act in a THIRD position. M2's claim acts on the word after a name word; M1's on the content of a configured pair; M3's now on the first word of any bracketed clause, under the default policy. #vocabulary-collisions C-i is keyed on the position a claim acts on, so extending where a rule acts re-asks C-i of the whole set it reads, and nothing in the process prompts that — the rule was designed, reviewed and approved without the set being re-examined. Review afterwards found one entry that fails C-i, the Czech abbreviation roz, and its disposition is this file's Excluded (MAIDEN_MARKERS) block, which is keyed to the vocabulary set rather than to any rule: it was removed. Two things worth separating, since the finding surfaced during this work and is easy to file under it — the defect was M2's and predates #335 (bare "Rosalind Roz Smith" lost its family name on master), and M3's own boundary already excluded the common one-word "(Roz)" spelling. What belongs to M3 is only the widening, and the lesson is the general one: re-ask C-i of a vocabulary set whenever a rule extends where its claim acts.
- 2026-08-26 #335 — what configuring maiden_delimiters still buys, since M3 removes the commonest reason to reach for it: markerless clauses, and one-word clauses. "Cherice J. (Johnson) Williams" is a real US convention and a corpus name, and nothing in that clause says maiden, so it stays a nickname by default — only a caller who knows their data can say otherwise. The knob is a declaration about the DATA; M3 is a reading of what a clause declares about itself.

### M4 — a maiden marker makes the lone name word the family

- 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name) — Derek's rationale, in his terms: normally if there is only one name we assume it is a given name because we have to pick one, but maiden markers mark a previous surname before marriage, so it would not make sense to include one if there was no other surname for it to clarify — and given that, when a marker leaves only one name word we switch the assumption and read that word as the family name. `parse("Smith née Jones")` reported given 'Smith' with an EMPTY family through 2.1 and reads family 'Smith', maiden 'Jones' since. The rule is written in post_rules as H1's sibling and placed directly under it, so the interaction is decidable by reading rather than by running: where H1 fired there is no GIVEN left for M4 to move, and where H1 declined because the title addresses by given name ("Sir John née Jones") the `not titles` guard keeps M4 off the same word. A titled name is H1's at both outcomes, which is what keeps H1's given-name-title carve-out intact.
- 2026-08-27 #445 — the third time in the 2.2 bundle that a name carrying a maiden clause reported no family, and the three share a SYMPTOM rather than a cause. State that carefully, because the first draft of this entry claimed one cause for all three and was wrong. #410 (H1): a title plus one name word reported no family the moment a suffix, nickname or maiden name stood beside it. #411 (P5's reserve): the bound given-name join counted the marker and the maiden name as words it could spend, and spent the family name. Those two ARE one cause -- a count that included words which are no part of the name -- and the grep that would find a fourth is a rule counting name words without first asking which of them are name words. #445 is not that: it changes no count anywhere (post_rules gains a new block, assign gains only a comment), and `parse("Smith")` with no clause beside it at all already read given 'Smith' with an empty family, so nothing was being swallowed. Its cause is the one the next entry gives: O4 decides nothing at one name word, and the reading came from a convention nobody had written down. The site that grep would miss is `_name_positions`'s `count == 1` branch, silent for every order and every name -- which is exactly what O5 now records.
- 2026-08-27 #445 — O4 was SILENT at exactly one name word, which is why the shipped reading had no rule to point at and why O5 now exists. O4 reads a name by comparing where its words stand ("the first name word is the given name, the last is the family name"); with one word the first IS the last, so there is nothing to compare and the rule decides nothing. O5 records the reading as the convention it is — a guess fixed in advance so that the same input always reads the same way, not a determination about the word — and names H1, N3 and M4 as the rules that DO decide such a name. Written that way deliberately: a rule asserting "a lone name word is the given name" as a fact would have made #445 a contradiction of the documented behavior instead of an exception to a documented guess, and would make #449 one too.
- 2026-08-27 #445 — the two carve-outs are not inventions, and both rest on mechanisms.md#TWO-LAYER-ASSIGN: the positional layer never overrides a vocabulary claim, and M4 changes only what POSITION decided. A word the vocabulary claims as a given name keeps that reading ('abd née Jones' — `vocab:bound-given`), and so does a word written as an initial ('J. née Jones Smith V' — the `initial` shape tag). Both are read off tags classify already recorded rather than off a predicate of M4's own, and both witnesses are real corpus names rather than constructed cases, so each carve-out has something in the differential that would notice its loss. Mutation-checked on a scratch copy: dropping the `initial` carve-out fails that name's case row, its facade twin, M4's own boundary example and the pre-existing assertion in test_the_chain_and_the_walk_stop_where_the_peel_begins; dropping `vocab:bound-given` fails the other name's two runners, M4's boundary, P5's own doc example, O5's own `abd née Jones` line and the corpus-wide maiden-clause property on 'abdul' -- six in all, the O5 line having been added by the review round, and the list is exhaustive as re-run against the final tree.
- 2026-08-27 #445 — the two halves of Derek's answer differ in their relationship to 1.4.0, and a reader should not have to re-derive it. The bracketed spelling RESTORES v1: `Smith (née Jones)` read family 'Smith', nickname 'née Jones' on 1.4.0, 2.0.0 and 2.1.0 alike, and now reads family 'Smith', maiden 'Jones' — the clause changes hands and the family name stays put, so the 1.4.0 gate's diff on that name SHRANK to {nickname, maiden}. The interior spelling is a NEW reading: `Jane née Jones Smith` read first 'Jane', middle 'née Jones', last 'Smith' on 1.4.0 and reads family 'Jane', maiden 'Jones Smith' now, the real surname being inside the maiden value by M2's greedy take. That the marker stands inside the name changes nothing for M4, which counts what the take LEAVES rather than where the marker stood — one name word left that way is one name word, which is the widest half of the decision and the half no earlier version agrees with.
- 2026-08-27 #445 — a precedence claim corrected while drafting, and it is exactly the class this bundle keeps producing. The first draft of M4 said a name carrying a nickname is N3's rather than this rule's. Measured false: `'Smitty' Jones Jr. née Smith` reads family 'Jones', because N3's count does not set a suffix aside, so N3 declines and M4 fires. A nickname holds nothing off — where N3 has already named the family M4 finds nothing left to move, and where N3 declined M4 names it. The general lesson is the one #410 and #411 already taught from the other side: a rule that counts name words and a rule that counts something else will disagree at the edges, and the disagreement is only visible if the interaction is measured rather than asserted.
- 2026-08-27 #445 — what the ledger's `# revisit when #445 lands` markers bought, and what they got wrong. Four blocks carried them, and each predicted that a "keep the family" fix would leave the diff a subset of the fields its rule already declared, so the rule would go on explaining the name and the change would be absorbed with the gate green. That was wrong in a way the rule's own shape settles: M4 MOVES the one name word rather than adding one, so `given` empties as `family` fills, the diff outgrows every declaration, and eight names arrived UNEXPLAINED at all three baselines. Two counts run through this and they are different sets, so name which is which: NINE corpus names change reading, of which eight arrive as new diffs — the ninth, 'Smith (née Jones)', has its 1.4.0 diff SHRINK instead, its family agreeing with v1 again. The four markers between them named NINE names to re-measure, of which seven moved: the two that did not are M4's carve-outs, and the two movers the markers could not name were not corpus names until this change's own examples added them. The markers earned their keep all the same — they named the right rule, the right field and very nearly the right set — so they are corrected in place rather than deleted, and the correction says what actually happened. THREE of the four moved in the opposite direction from their own prediction, and getting that number right took two passes. The fix(#335) rule for 'Smith (née Jones)' appears in all three ledgers, and every copy feared absorbing a WIDENED diff while the diff in fact SHRANK to {nickname, maiden}. The first pass narrowed only the 1.4.0 copy, on a measurement of that wheel, and left the other two at four fields on the reasoning that 2.0.0 and 2.1.0 'read `given`, and the baseline cannot move'. They did not: only the BARE spelling ever read `given`, and this rule holds the bracketed one, which reads family 'Smith', nickname 'née Jones' on every released version. So two rules stood over a two-field diff declaring four, and classify's subset test would have explained a `given`/`family` regression on that name in silence -- the absorption those very notes existed to prevent, surviving the round that answered them. A third reviewer found it by instrumenting the comparator to dump real diff-field sets, and that is the lesson worth keeping: ledger prose cannot be checked by reading it, and the round that corrects a marker is exactly where a wrong premise gets written down with confidence.
- 2026-08-27 #445 — the corpus-wide property test is the repair worth copying. tests/v2/test_parser.py's `test_a_maiden_clause_changes_nothing_else` asserts over the corpus that appending " née Jones" adds a maiden name and moves no other field, and M4 falsifies it for fourteen names. Skipping them would have bought a green suite and lost the check; instead the test computes M4's guard from the base parse's tokens and asserts the flip — family takes what `given` held, every other field standing still — so the class that used to be a hole is now the strongest witness the rule has, fourteen names against the six rows cases.py carries. It is also what executes M4's nickname precedence, `'Smitty' Jones Jr.` being one of the fourteen.
- 2026-08-27 #445 (review round) — M4 is keyed on the maiden NAME, not on a marker, and the statement was corrected to say so after two reviewers measured the same defect independently. The guard tests for a token in the MAIDEN role, and M1's caller-configured pair produces one with no marker anywhere: under `Policy(maiden_delimiters=frozenset({("(", ")")}))`, `Smith (Jones)` reads family 'Smith', maiden 'Jones', and so do M1's own boundary examples `Smith (Nee)` and `Smith (z domu)`. The BEHAVIOUR is right — the rationale transfers to a declared pair without a word of change, since what announces a former surname is the clause, not the vocabulary that marked it — so the fix was to the rule, which had said "a recognized maiden marker that takes its name and leaves exactly one name word" and now says "a maiden name standing beside exactly one name word". That covers M1, M2 and M3 uniformly, M1 is in M4's `interacts:` and M4 in M1's, and the configured path has an example line of its own. The general lesson is the one this bundle keeps teaching from a new angle: a statement written from the case that prompted the change describes that case, not the code, and only measuring the OTHER paths into the same guard finds the difference.
- 2026-08-27 #445 (review round) — a known gap recorded rather than closed, the way H1 carries its own. M4's guard counts GIVEN tokens; H1 counts nothing at all (it tests which roles are unoccupied). So a name word another rule has joined counts as several here and M4 declines where H1 fires: `Dr. Dean of Chemistry` reads family 'Dean of Chemistry' while `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry', the connective join (P3) having left three GIVEN tokens. rules.md#P3 says the joined part is one name word wherever another rule counts them, so the two siblings genuinely disagree and this is a gap rather than a boundary. Not closed here: widening the count to units moves zero corpus names (measured), and a behaviour change nobody has approved does not belong in a branch whose blast radius was settled. The sibling claim in the code comment and in this entry is narrowed to match — sibling except in what it counts.
- 2026-08-27 #445 — the relationship to #449, briefly, because the two look like the same question and are not. #449 asks whether a lone name word with nothing to decide it should REPORT an ambiguity; #445 removes a class of names from that population by giving them something that decides. So #445 reduces #449's reach rather than competing with it, and #449's measurement — a naive "one name word, nothing decided it" condition fires on 19 of 1,085 corpus names, only three or four of them the case it is about — is re-taken after this lands. O5 cites #449 in prose and deliberately carries no `deviates:` marker: that marker asserts an intended output the runner then checks strictly, and #449 has not decided one.

### O1 — East Slavic rotation

- (v1 era, PR #154) — why patronymic handling is OPT-IN at all: unconditional detection breaks ordinary Latin names whose endings collide (Martin, Franklin, Benjamin), the finding that forced v1's Russian work behind a flag and set the opt-in shape v2 inherited.
- 2026-07-12 (landed in the v2 core, PR #288) — v1 parity pinned live: the rotation reconstructs token position from assigned roles, which is faithful to v1 only under the default given-first order.

- 2026-08-15 — the rotation × non-default name_order interaction question rode #270, which closed 2026-07-28 with the order constants and no recorded answer for the rotations.

Open:
[#384](https://github.com/derek73/python-nameparser/issues/384)
what the rotations should do under a non-default name_order (the divergent measurement is in the issue).

### O2 — Turkic rotation

- 2026-07-02 (Turkic design; landed in the v2 core, PR #288) — shape fixed at exactly four name words (1 given + 2 middle + 1 marker), v1 parity; other shapes keep their positional reading even when that leaves the marker in a name field (see the rule's Accepted consequence).

- 2026-07-02 (design, orthography coverage) — dotless ı does not case-fold to i, so qızı is a literal alternative in the pattern (re.I cannot bridge it; all-caps QIZI falls to the ASCII alternative); the patterns are NFC literals and NFD input does not match. Scoping decline: suffix-attached Kazakh/Uzbek patronymics (Әбішұлы) are bound-suffix morphology, not a standalone marker, and deliberately out.
- 2026-08-15 — same rotation/name_order interaction status as O1.

Open:
[#384](https://github.com/derek73/python-nameparser/issues/384)
same question as O1.

### differential-ledger — tooling decisions (2.1.0 release arc)

Harvested 2026-08-15 from the release-arc session; measurements are that session's, spot-checked at landing.

- 2026-07-24 (rc1 arc, predating this section's window) — the TWO-CORPUS design: corpus_issues.jsonl (198 tracker-harvested names, 166 absent from the v1-test-bank corpus) exists because v1 test banks are structurally blind to anything 2.0 added; compare.py globs corpus*.jsonl and fails loudly on none. Its first catch was the leading-credential case ("Ph. D. John Smith" → suffix).

- 2026-08-05 #332 — ledger field vocabulary is Role's names, not the facade's: canonicalizing to first/last would have put an eighth place naming roles differently from Role inside the durable record, and the facade vocabulary is removed at 3.0.

Declined:

- Policy annotations widened to input unions (2026-08-05 #334) — five documented spellings fail mypy and every one has a type-clean equivalent; widening would make every READER see a union, and reading is the commoner operation. A .pyi stub typing __init__ wide and attributes narrow was deferred as a 3.0 candidate (needs a parallel signature list with its own sync test).
- Closing the differential gate's default-policy ceiling (2026-08-04 #332) — a per-row policy in the corpus needs defined behavior for baselines that cannot construct newer policy fields; tests/v2/cases.py already covers opt-in paths per row, so the ceiling is documented instead.
- `_check_tree` as is_relative_to(REPO_ROOT) (2026-08-05 #332) — accepts .venv/, build/ and dist/ copies inside the repo; the invariant is "is the source package", so the predicate is is_relative_to(REPO_ROOT / "nameparser").
- Empty-string probe as the ledger over-match guard (2026-08-05
  #332) — `.`, `.+`, `\b`, `[\s\S]` all decline "" and still match
  every corpus name; replaced by the sentinel set (mechanisms.md#SENTINEL-SET-OVER-MATCH-CHECK).
- "Lists every role" checked against all eight fields entries (2026-08-05 #332) — a seven-role list passed while omitting _ambiguities, which below baseline 2.0 cannot enter a diff at all; the check is against V2_FIELDS.
- A seed ledger rule with name_regex and no fields (2026-08-05
  #332) — it matched every CJK-bearing name and would have
  classified all 89 diffs on the first pass, exiting 0 having distinguished nothing.
- 2026-08-07 #333 — the canonical-rule selector (keyed on literal
  #271/#272 slug substrings) was deleted rather than repaired,
  option A of three: split "every hand copy equals the table" (the sweep's job) from "the table did not change shape without a decision" (the span-bearing test's job), so a selector break can never take the decision gate out as collateral. This retired the slug taboo an earlier mechanisms.md entry carried.
- The B7 sanctioned-extra span (pre-#332 arc) — added, then removed as a pure loss when review measured that every B7-divided name already matches through its guaranteed classified flanks, so the span's only effect was absorbing punt-volat Latin regressions ("Gal·la Marcet" probes on both sides, recorded in PR #305's history). The cleanest "sanctioned extras must be earned" precedent the ledger has.
- An issue for rotating DEFAULT_BASELINE (2026-08-07) — it recurs every release; it lives in the AGENTS.md release checklist beside the VERSION bump instead (#333 must land first).

Excluded (ledger name_regex patterns — one over-matching rule shadows the whole ledger, since name_regex rules sort first): "",
"(?:)", ".", ".+", "\b", "[\s\S]". Enforced by the sentinel-set
check.

### differential-ledger, the dormancy arc (2026-08, #328–#376)

The second ledger arc; measurements are that session's, the machinery verified present at landing.

Decisions that landed:

- 2026-08 #328 — `[[never]]` exclusions: ledger vocabulary for
  "this shape must never be explained". classify() consults
  exclusions before rules, which makes them MONOTONE — an entry only ever removes a name, never moves one between rules — so an exclusion's blast radius is exactly the names it captures, independent of rule order.
- 2026-08-12 #373 — `dormant = "<reason>"`: a rule explaining nothing fails the run in both directions, with three diagnoses (reverted / shadowed by X / refused by a [[never]] entry). Always on, not behind --strict: a check nobody passes a flag to is a check that doesn't exist.
- 2026-08 #373 — `--baseline 2.0.0` joined the release checklist: that ledger's rules had no dynamic coverage at all; measured clean at 90/0, so the gap was closed rather than documented.

Declined:

- Ambiguity reporting on multi-matching rules (#372/#373) — measured: 28% of claimed name×role pairs already have ≥2 matching rules (732 of 2619; 432 are one pair of rules alone). A report firing on 28% of what it inspects is wallpaper. The actionable slice shipped as the "shadowed by <issue>" diagnosis, which speaks only on FULL shadowing.
- A specificity floor for fields-only rules (#372/#373) — when this was proposed, exactly one fields-only rule existed across the ledgers, naming 3 of 7 roles; a six-of-seven floor matched nothing, and nothing would reveal it vacuous. Still declined: #451 banned the SHAPE instead (the fields-only arc below), which is a different proposal and leaves no ledger with a rule for a floor to measure.
- Specificity reordering of the rule sort (#328) — measured across all 751 names, on the ledger as it then stood: width-then-regex-length moved five rule populations and sent 17 names into the generic fields-only rule, draining the CJK-specific ones. No reading of the sort produces the "exactly one label changes" originally claimed; that figure was corrected on the PR.
- Unanchoring the honorific-suffix rule to reach glued forms (#376) — it would make a future suffix regression on 김지양 classify as a recognized honorific. A confidently wrong label is worse than a catch-all's honest breadth, and the gate reads the same either way.

### differential-ledger, the fields-only arc (2026-08-27, #451)

The third ledger arc, and the one that ends a shape rather than adding vocabulary. Measurements are this session's, at the counts each commit records.

Decisions that landed:

- 2026-08-27 #451 — `validate_rules` REJECTS a rule carrying `fields` and no `name_regex`. Such a rule claims every name whose diff fits those fields, and `_CORPUS_CLAIMS` records a regexless rule's reach as the WHOLE corpus — already at its maximum, so arrivals never move it and `test_every_rule_claims_the_recorded_share_of_the_corpus` passes whether it explains 4 names or 40. The one rule with the shape drifted from the four names its prose described to 25 before #372 cut it back, and then to SEVENTEEN after — decisions.md#H1 records that 14 -> 17 step and #410 taking three back — standing at fourteen across six unrelated behaviour families when this bundle opened, every guard green throughout, absorbing two open v2.2 bugs (#342, #397) as intentional 2.0 changes while AGENTS.md names the 1.4 summary as the release log's source for Behavior Changes. Free to state only because the fourteen were given rules first; a role-count floor was the earlier proposal and stays declined (#372/#373, vacuous).
- 2026-08-27 #451 — a rule whose declared `fields` EXCEED every diff it explains is a defect in its own right, not untidiness. Measured on the catch-all: it declared `{given, family, suffix}`, and by the time this bundle's first three commits had given its `{given, family}` names rules of their own, nothing it still explained moved `given`. Note WHEN the excess appeared, because it is the whole mechanism — the declaration was earned when the rule was written and became excess as its explained set SHRANK under it, which is exactly `fix(#424)` at decisions.md#H1, where #410 narrowed a diff from three roles to two and the rule kept matching by superset with no run naming it. A rule does not have to widen to become over-declared; the diffs beneath it only have to leave. The two together are why #452 was filed — nothing counts what a rule actually explains, only whether it explains anything.
- 2026-08-27 #451 — a ledger rule may classify a reading NOBODY WANTS, provided its `issue` says so. `fix(#342)` and `fix(#397)` carry NOT WANTED in the issue text, the cause-not-consent sentence decisions.md#P5 established for the #369 regression, and a delete-when-fixed instruction the dormancy check enforces once the fix lands.
- 2026-08-27 #451 — one vocabulary per alternation, FORCED rather than chosen. `_LatinCopy` carries one `vocabulary` and `test_latin_alternations_mean_something_the_vocabulary_ships` asserts exactly one roster key per alternation, so `jr` (SUFFIX_WORDS) and `mc`/`mp` (SUFFIX_ACRONYMS) cannot share one. That constraint forces one of the three splits, and forces it only given the roster as written: `_LatinCopy.vocabulary` is a hand-supplied frozenset, so declaring `SUFFIX_WORDS | SUFFIX_ACRONYMS` would let `(jr\.?|v|mp|mc)` pass the member checks — measured. Declining to write a union vocabulary is a judgement, that a rule's alternation should name one wordlist a reader can go and check, and it is recorded here as one rather than as a constraint. The other two are forced separately and are recorded below under the declined single-rule tail: `m\.?a\.?` cannot be an alternation member at all, and the numeral alternation copies no wordlist. Four rules, three independent reasons, none of them taste.
- 2026-08-27 #451 — a ledger rule's character class must not reach a codepoint the IMPLEMENTATION does not act on, and is pinned to that. A SUBSET, not a copy: `test_the_emoji_boundary_rule_copies_the_dividing_ranges` asserts `claimed - divides == set()` and nothing in the other direction, deliberately — `_EMOJI_RANGES` has FOUR blocks and the rule copies the two astral ones, leaving the BMP pair out because no corpus name reaches them through its token anchor. Reading it as equality would instruct the next author to pre-claim the BMP half, which is the #372 shape this same section leans on. The emoji rule's first draft spanned `U+1F300-U+1FAFF` while the two ASTRAL blocks of `_tokenize.py`'s `_EMOJI_RANGES` are narrower than that span: it claimed 1072 codepoints where the parser leaves the token whole, so its own prose ("an emoji inside a token divides it") was false for every one of them and a `{given, family}` diff with another cause would have classified as intended. `test_the_emoji_boundary_rule_copies_the_dividing_ranges` holds it, and fails on the draft it replaced.
- 2026-08-27 #451 — `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads. Verified against all three: the ban removes the only shape that could occupy its second tier, so file order settles every tie there is and the function reorders nothing. Kept because it is what makes the ban safe to state for any reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture. Deleting it would move the guarantee from the code into a convention, which is the trade #451 was filed to undo. Recorded as a decision rather than as a consequence precisely so a later dead-code sweep finds the reasoning before the `return`.
- 2026-08-27 #451 — past-tense counts stay in a ledger comment; live ones do not. "It claimed 25 until #372" is a fixed fact. "Four corpus names, and the prose fits all four" was written when the rule explained four and still read that way at fourteen, because nothing recomputes it. Where a count is owned by another comment, point at the owner rather than restating it — two copies means one gets updated and the other does not.
- 2026-08-28 #452 — a rule's declared `fields` must EQUAL the union of the diffs it explains, checked by `compare.py` at the end of every run and failing it like an unexplained diff. The statement is exact rather than heuristic, which is what makes it cheap: `classify()` already requires `declared >= union` for the rule to match the names it matches, so the only possible error is the other direction, and the union is simultaneously the check and the repair. Narrowing to it cannot orphan a name, since every name a rule explains contributed to it. Measured before landing: 3 of 67 explaining rules over-declared at 1.4.0, 5 of 58 at 2.0.0, 6 of 51 at 2.1.0 — all fourteen narrowed first, so the check was silent the day it arrived.
- 2026-08-28 #452 — over-declaration is BASELINE-RELATIVE, and each ledger is measured on its own run rather than copied. `fix(#296) a lone post-comma credential is a suffix` declared `{family, given, suffix, title}` in all three ledgers: exactly exercised at 1.4.0, where v1 reads the pre-comma word as `first` and all four roles move, and over-declared at both 2.x baselines, where the same behaviour moves only `{suffix, title}` — which is all it declares in those two files now. A reader comparing the three sees one rule with TWO different field lists and should read that as correct rather than as drift. Two and not three, measured: the 2.x pair narrowed to the same set, so the split is 1.4.0 against both 2.x ledgers, not one list per file.
- 2026-08-28 #452 — NO escape hatch, decided rather than deferred. `dormant` already covers the explains-nothing case in both directions, a rule with no `fields` has nothing to over-declare, and the ledger's own doctrine — "a rule that pre-claims shapes it has never seen is the #372 failure mode" — makes strictness the existing principle. Accepted cost, stated so it is not rediscovered as a surprise: the first rule that genuinely needs a wider declaration has to argue for a key the way `dormant` was argued for in #373, rather than reaching for one that already exists.
- 2026-08-29 #456 — a rule carrying `name_regex` and no `fields` is REJECTED, the symmetric twin of #451's ban. It narrows by name and by nothing else, so on any name its regex reaches it claims every diff shape there is — measured, 255 shapes from baseline 2.0 on and 127 below it. #452 is what made it urgent rather than merely untidy: `over_declared_rules` skips a rule with no `fields`, correctly, since one declaring no roles cannot over-declare them — so deleting the line is the cheapest way to silence an OVER-DECLARED failure AND the most permissive thing that can be done to the rule, which is the #372 failure mode reached by following a gate error message. Free to enforce on #451's own terms: measured, 0 of 179 rules across the three ledgers had the shape. After it, every rule carries both keys, and the three rejections in `validate_rules` read as one rule — narrow by name and by role, or it is not a rule. NO escape hatch for a "genuinely unbounded" rule, declined until one appears, same call and same reason as #452's.

Found rather than decided, and worth as much:

- **A fields-only rule silently falsifies OTHER comments.** The 2.x ledgers' idiom "such a name should arrive UNEXPLAINED and be read once" holds there because no rule above the one making the promise claims that diff shape. A fields-only rule is the extreme way to break it, not the only way: measured while fixing #452, a `{family, maiden}` diff on `fix(#367)`'s own probe goes to `fix(#274)` in the 1.4.0 ledger, which has no fields-only rule anywhere. Note the shape, not the role: `{given, family, maiden}` on the same probe is claimed by nothing, because `fix(#274)`'s `fields` stop at maiden/middle/family. That is the point — the question is only ever answerable per shape. The reusable check is "does anything above me claim this shape", which is `classify()` driven directly — NOT "is there a catch-all". In the 1.4.0 ledger it was false for anything of the catch-all's shape, and nothing said so. Measured while the rule still lived, by driving `compare.classify()` directly over the seven `_MUST_NOT_MATCH` probes of the two rules #451 ported, at diff shape `{given, family}`: ZERO arrived unexplained. Four went to the catch-all ('Mcintyre Smith Jr.', 'Los Santos', 'John Sir de Mesnil', 'Sir Smith'), two to `fix(comma-precomma-family)` ('McDonald, Ronald', 'Smith, Sir de Vaux'), and one ('Ste Marie') to the neighbouring `fix(#360) ste` rule that exists to claim it. Driven through `classify()` rather than read off a gate run, deliberately: six of the seven are in no corpus, so a run would never reach them — which is also why the promise they carried could go false unnoticed. The blast radius of the shape was never just its own heading.
- **Copy-paste is how a ledger comment goes false without being edited.** All three ledgers carried the same `fix(#379)` comment; four of its sentences were true only in the 1.4.0 file they were written for, including one citing `fix(suffix-routing)`, which exists in no 2.x ledger, and one saying a name "falls to the fields-only catch-all" twenty lines after the same file says no fields-only rule exists there. Corrected in `8237b54`. A cross-ledger claim needs re-deriving per file, not copying.

Declined:

- A `[[never]]` for #342 and #397 (2026-08-27) — honest, and it reddens the gate: an excluded name reports UNEXPLAINED and exits non-zero, so every release would block until both bugs are fixed. Both are on v2.2, so the window is real. The cause-not-consent rule says the same thing in the ledger and keeps the gate green.
- One replacement rule with a `(jr|v|mp|x|m\.?a\.?|mc)` tail (2026-08-27) — measured, not argued: `m\.?a\.?` matches the corpus fragment `'M.A.'`, which `_normalize` leaves as `'m.a'` and is not a `SUFFIX_ACRONYMS` entry, so the member guard rejects it — the same finding the `ambiguous-surname-acronym` rule already records. `x` is in no vocabulary set at all, reaching `suffix` through the trailing-roman-numeral fork, so it could not be a member either.
- A literal alternation of all 14 absorbed names (2026-08-27), on #413's literal-anchored precedent — kept as the fallback if a reading turned out contested, and not needed: every family had an existing rule in another ledger, a decision to cite, or an open issue.
- Extending #451 to the trailing-numeral ambiguity (2026-08-27) — `'Mohamad X'` loses its family to `_ROMAN`, and by C-i's "under uncertainty, default to AMBIGUOUS" a trailing `X` IS borne as an ordinary name in that position (Malcolm X). Numerals reach `suffix` through a pattern rather than a vocabulary set, so there is no ambiguous SUBSET for `x` to join — but the fork does not go unreported: measured, `parse('Mohamad X')` and `parse('John V')` both carry `AmbiguityKind.SUFFIX_OR_NAME`, which `_assign` raises from the fork itself because "V/X/I are ordinary middle initials, so taking it as a suffix is a call, not a fact". The contrast worth keeping is with the acronym half: `parse('Aishwarya Rai')` loses its family and reports NOTHING, because `rai` is unambiguous vocabulary. So the numeral fork already flags its own uncertainty and the vocabulary path does not — which is an argument about `SUFFIX_ACRONYMS_AMBIGUOUS`, not about `_ROMAN`, and it belongs to #342 rather than here. `'Malcolm X'` is in no corpus (`'Mohamad X'` is, and is what this bundle classified); no open issue on the numeral half.
- `explains = N` per rule (2026-08-28, #452's own first sketch) — catches a rule explaining fewer NAMES even while its fields stay exercised, which the field check does not. Declined on maintenance cost rather than merit: 179 numbers across the three ledgers, every one of them liable to move whenever a corpus does rather than only when a rule does. Measured on the precedent — #414's harvest moved 17 of the 30 `_CORPUS_CLAIMS` entries that existed then and added 14 more, parsing the roster at `7a10689^` and `7a10689`. (That roster's own comment said "Twenty of these moved" and was wrong; measured three ways — that commit, the whole of PR #415, and the backtick-harvest commit after it — the figure is 17 every time, and the comment now says so with the recipe.) The field check needs no number and cannot go stale. This is the design to revisit if a name-count shrink ever bites.
- Extending it to `'Donald mc'` (2026-08-27) — `mc` is `SUFFIX_ACRONYMS` AND `PARTICLES`, the collision C-ii decided for `vd`, and C-ii reaches this shape only through P6's attachment, which is scoped to the comma form. The authority is rules.md#S2's Accepted — "an unambiguous suffix is consumed even when that leaves no family name at all" — and NOT P6's scope note, which was this entry's first citation and does not support it. P6 promises the comma-less shapes "keep their positional reading", and for the words that are both particle and suffix vocabulary it does not hold: measured, `'Jong de'` and `'Smith do'` keep theirs (family 'de', family 'do') while `'Donald mc'` and `'Smith vd'` lose the family entirely to `suffix`. So `'Donald mc'` is out of #451's scope on S2's statement, and P6's own Accepted clause is falsified by two of the three words it sweeps in — its only example, `"Jong Anke de"`, is `de`-shaped, which is why no test catches it. Recorded here rather than fixed; it is a rules.md defect, not a ledger one.

### comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01)

#291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with
its decline-by-measurement evidence in the issue body; "z domu" folded into it 2026-07-27 (see Excluded, MAIDEN_MARKERS). Intent for #291 and #296 is settled by the approved bundle spec; #296 (with #325) SHIPPED 2026-08-23 as the bundle's first three commits, with one deviation from the audit table recorded at C1 ('md' keeps dual membership — bare 'Md' is Muhammad); #291 is UNSHIPPED, and rules.md carries it as a deviates: marker on C1. The arc's bookkeeping: #291/#296 moved milestone v2.1 → v2.2 on 2026-08-01 (with #289/#293); the 2026-07-30 design doc was amended in place 2026-08-01 (A1–A6); its durable content is HERE rather than in the gitignored doc; #316 (trailing titles) was filed the same day, carrying the esq cleanup as a Related section.

The approved 19-word TITLES∩suffix audit table (2026-07-30/08-01, intent for the unshipped bundle): jr, junior, phd, md, do and se DROP from TITLES (se: Structural Engineer is the PE/SE post-nominal pair, no prenominal convention — v1 residue); dr drops from SUFFIX_WORDS and sra from the suffix sets (v1 residue); sr, lt, cpl, cpt, cpo, csm, sgm, ra and vc KEEP dual membership (position decides); ms and sa keep dual membership AND join the ambiguous set (the periods gate). Derek's framing thesis for the whole table: v1's sets encoded where the v1 PARSER needed words to be, not where words can occur. Amendment A6: once SUFFIX_PHRASES ships, the glued peel's token-level post-nominal test cannot step over a phrase segment ("김민준씨, LEED AP" will not peel) — #291 owns deciding that, with a case row either way.

- 2026-08-01 (plan amendment 1, Derek's call: "ship + repair the merge") — dropping dr/ms from the suffix vocabulary flips segment() to FAMILY_COMMA and degrades "John Smith, Dr." to family="John Smith" (mechanisms.md#VOCABULARY-FEEDS-STRUCTURE is this shape); the approved repair, ordered FIRST in the bundle so no commit introduces a regression a later one heals: assign segment 0 positionally when segment 1 is nothing but titles.

Declined:

- Splitting the dead multi-word suffix entries into single-word entries (2026-07-26, the evidence in #291's body) — measured:
  "Smith, A.P." has the suffix steal the given initials; "John
  Leed" and "Mary Nicet" lose family names; and the period-gate escape is equivalent to removal because nobody writes "L.E.E.D.". This decline is why the seven removable entries were REMOVED in 2.0 rather than split, and it is the missing history behind C1's
  #291 marker.
- The trailing-abbreviation structural fallback — with a measurement LIMIT rather than a measurement: the differential corpora structurally cannot evidence it, because they hold only names someone wrote down, and an unrecognized abbreviation is by definition outside the vocabulary. A green run there proves nothing (the reusable harness fact is in mechanisms.md's field notes).
- SUFFIX_PHRASES matching in assignment — measured cost: it renders suffix="LEED, AP", because the suffix view comma-joins suffix words unless they carry the stable "joined" tag, which only grouping applies. The general form: multi-word vocabulary must merge where the render tag is applied, not where the role is assigned.

### api-churn-declines — do not re-propose without new cause

Declined (2026-07-06, the post-1.3.0 modernization sweep; each on churn-outweighs-benefit grounds): the hn.C → constants rename; capitalization-knob consolidation; the logger rename. (Lenient matching, declined in the same sweep, is recorded under comparison-surface.)

### initials-repertoire — is_initial across scripts (#320)

- The principle: alphabetic-vs-CJK, not Latin-vs-non-Latin — Han, kana and hangul characters are morphemes or syllables, so a single one cannot stand in for a name word, while any alphabet's single letter can. _NO_INITIALS is ENUMERATED per script rather than derived from the script ranges, so a new Script member forces a decision instead of silently deciding Thai has no initials.

Declined:

- Narrowing is_initial to [A-Za-z] — measured: "Й." regresses to the #267 Ukrainian-conjunction misreading (the exact regression 2.1.0's release log claims to prevent), and the stable "initial" tag silently strips from every Cyrillic, Greek, Arabic and Hebrew initial, invisibly to the fields.

### comparison-surface — why value equality died, and what replaced it

- 2026-07 (1.3.0 arc, #223/#224; executed in the v2 core — measured: HumanName == HumanName is False, matches() and comparison_key() live, the facade cites #223) — the trilemma: case-insensitive equality, equality-with-plain-strings, and hashability are mutually inconsistent (a hash cannot equal both hash("John Smith") and hash("john smith")), so 1.2.0's design was unfixable in place, not merely disliked. Additional costs of the old surface: equality ran through str(self) and so depended on mutable string_format, and maiden was invisible to == (absent from the default format).
- 2026-07-06 — the cross-constants asymmetry, pinned: a str operand is reparsed with self.C; a HumanName operand is compared as already parsed.

Declined:

- Component-based __eq__/__hash__ — workable, considered, rejected: it still privileged one equality semantic for a domain that has none, and kept the mutate-while-hashed hazard.
- Lenient matching (initials-compatible, Bob/Robert) — declined WITHOUT an issue, deliberately: matches() is exact-components by design. Someone will propose could_match() within a year; this entry is the resolved-as-no they should find.

### R2 — the all-particles family_base divergence

- 2026-08-18 #404/#385 — R2's DISCRIMINATOR REVERSED. It said a family written wholly out of particle vocabulary has a base where one of those words "is itself borne as an ordinary surname", and that only the words that are never anyone's name stay particles. That is a vocabulary test, and it is the wrong one: a particle earns its name by joining forward to the word it modifies, so a particle with nothing to join is not doing a particle's work whatever it is borne as. POSITION decides, and the borne-as-surname question does not arise.
  The invariant that replaces it, and the reason this is worth a rule rather than three bug fixes: a non-empty family always has a non-empty base. A particle needs a base to attach to. Measured, 43 of 751 corpus names x 3 orders violated it — "Del Toro" under FAMILY_FIRST reported family 'Del' with no base, losing the surname from the base and from the initials.
  Marked rather than untagged (mechanisms.md#MARK-DONT-STRIP): `particle` is stable API and means "from the particle vocabulary wherever it lands", which stays true; UNJOINED_TAG records what was decided. That also leaves #405's ambiguity emitter a predicate to key on, which stripping would have destroyed for exactly the never-given half where the fork is most interesting.
  Blast radius: 44 of 2,253 parses move and every one is VIEWS-only — the seven role fields are byte-identical everywhere, so the differential harness cannot see this change at all (it compares roles only). Tests are the whole verification, and the invariant is one of them.

- Recorded 2026-08-16, intent UNVERIFIED: the v1-era design held that a family name cannot be only particles ("Anh Do" — Do is a surname AND a particle), so last_base was guarded non-empty. The facade still guards (HumanName("Anh Do").last_base == "Do"); the v2 core does not (parse("Anh Do").family_base == "", family_particles == "Do"), and the surname vanishes from initials (parse("Anh Do").initials() == "A.").
- 2026-08-16 #385 RESOLVED by the collision criterion, not on its own terms: the issue's option 3 ("guard the view only when a word is vocabulary-ambiguous") is what decisions.md#vocabulary-collisions produces when applied here.
  "Do" is borne as an ordinary surname, so it is ambiguous
  vocabulary and anchors the base; "van der" is never anyone's name, so an all-particle family there genuinely has no base. The two rows of the issue's table were never one case. This is the keystone's clearest payoff: #385 was filed as a leaf with three options and no way to choose between them, and the criterion picks one without arguing about family_base at all.
- ANSWERED 2026-08-18 by the entry above, and recorded rather than left implicit: "Do" does NOT remain in family_particles once it is the base. The two views partition the family — parse("Anh Do").family_particles is "" and family_base is "Do" — which is asserted as a partition over the whole case table, not just as an example. Was: "Still open inside the resolution: whether Do remains in family_particles once it is also the base. Recorded here rather than left to the implementing PR to decide by accident."
- 2026-08-16 (pre-merge coherence pass) — the resolution moves R3 too, and R3 now carries its own marker. Initials read the BASE family word, so anchoring "Do" changes parse("Anh Do").initials() from "A." to "A. D." while
  "Juan van der" stays "J." (no borne name, no base, and initials
  of a bare particle run would be nonsense). The general lesson, worth more than this instance: a deviates: marker gets written on the rule whose STATEMENT changed, but a rule can change another rule's OUTPUT without touching its statement, and nothing looks for that — the runner asserts per example line, so an unmarked downstream rule stays green precisely because its own examples avoid the affected input. When adding a marker, walk the changed rule's `interacts:` targets and ask whether any of THEIR examples move.

- 2026-08-29 #461 — TRIED AND BACKED OUT; the question is back on the issue. The unjoined mark readmits the words of an all-particle part to `initials()`, and #461 narrowed that readmission to the `particle` tag alone, on the authority of R3's "A CONJUNCTION never initials, so a base that is one contributes nothing even then". It was reverted before merge, in the same PR. What the narrowing got wrong is best said in terms of the MARK rather than of the fields. The mark is a statement about a whole PART — none of its words is doing the work its tag names — and #461 honored that statement for some of the part's words while keeping one of them out. Under `Lexicon.default().add(particles={'y'})`, `parse("Juan de y")` is such a part: `family_base` is `de y`, both words carry the mark, and #461 initialed `J. d.`, admitting the `de` as the name word the mark makes it and refusing the `y`. R2's reasoning does not split that way. A particle with nothing left to join is not acting as a particle, which is the whole reason the mark exists; a conjunction inside that same part has nothing left to join either, for the same reason and at the same moment, so it is a name word of the part and initials with the rest. Scope that carefully, because its general form is far wider than the claim and would be wrong: this is about a part the mark has ALREADY turned into name words, not about any conjunction that happens to join nothing. Outside such a part the skip stands whatever the conjunction is or is not joining, and none of those moved or may move — `parse("Juan y Garcia")` initials `J. G.` with the `y` a middle name word by P3, `parse("Juan Velasquez y Garcia")` initials `J. V. G.` over base `Velasquez y Garcia`, `parse("Jon Dough and")` initials `J. D.` over base `Dough and`. Base and initials differ in every one of those, legitimately, and R3's own `"Juan de y" → initials="J."` line is a fourth, its `family_base` being `y` under the default vocabulary. So "the base holds a word the initials do not" is NOT the criterion and was not the finding; the mark is. What is therefore in question is R3's "even then" clause, which carries the carve-out into the one part where the joining has stopped — not the code that failed to implement it. The clause is left standing and #461 now asks whether it belongs there; nothing else in R3 moved, and the paragraph #461 added under it is gone. R4's conjunction sentence rests on that clause by name ("the carve-out R3 states for initials") and is untouched here, so whoever settles #461 settles R4's cross-reference with it. Note what the backout cannot record: a `deviates:` marker hangs on an example LINE and this shape has none to hang on — `particles` and `conjunctions` are disjoint in the default vocabulary and in every locale pack, so no input string the doc runner parses reaches it. rules.md itself therefore carries no trace, and this bullet with mechanisms.md#RENDER-HONORS-THE-PARSE is where the gap is written down. It is not the only gap under R3's conjunction sentence, and the other is older, wider and markable: "A CONJUNCTION never initials" is unqualified, while a conjunction in the GIVEN group has always initialed — `parse("John and Jane Smith")` gives `J. a. J. S.` and `parse("Duke of Edinburgh")` `D. o. E.`, 25 of the 1094 corpus names, all reachable from the default vocabulary. Recorded here because it was found here; it is not #461's to fix. RESOLVED into the normative document on 2026-08-29, in the review of PR #463: rules.md#R3 now scopes its carve-out to the middle and base family words and declares the given group unsettled, in prose and WITHOUT a `deviates:` marker. The marker was considered and rejected on its own definition — it states the INTENDED value, and there is none to state. R3 counts name words while P3 makes a connective and its neighbours ONE name word, so `John and Jane Smith` has four candidate answers and no entry anywhere picks one: `J. a. J. S.` today, `J. J. S.` reading the carve-out as written, `J. S.` reading P3's join as a single name word, and `J a J. S.` at 1.4.0 (measured on the released wheel, which joins the run rather than skipping the conjunction). Marking it would put an invented value in a normative document and hold the parser to it, since the runner asserts the today-value strictly. The marker's `#N` slot has no owner either — the sentence above disclaims #461 for this gap, and filing its own issue is a maintainer's call rather than a docs commit's. What "markable" meant two sentences up is the MECHANICAL property that an input string exists to hang a marker on, unlike the #461 shape where none does; it was never a claim that the intended value is known. Neither direction is visible to the gate: 0 of the 1094 corpus names move either way, and the counts hold at 229 / 194 / 102 with 0 unexplained at 1.4.0 / 2.0.0 / 2.1.0. Both views of the contested token are pinned rather than left to prose — `test_repair_keeps_a_conjunction_lowercase_in_a_particle_part` holds R4's ungated conjunct, gating which had passed the entire suite until #461's test existed, and `test_initials_readmits_a_conjunction_in_a_particle_part` holds today's initials answer the way a `deviates:` marker would, so re-deciding #461 fails the suite until that pin moves with it.

Declined:

- 2026-08-18 — the GROUPING half of #404: a particle run that joins nothing does not chain, so "Jong van der" would split into middle 'van' plus family 'der'. Measured and rejected, though NOT for the reason first recorded here. The first draft said the split makes the family "no longer all-particle so the base fix stops firing" — false, and `der` and `la` are both shipped particles, so a family of either IS all-particle and the rule fires on it ("Juan Smith der" gives base 'der'). What the split actually costs is the SCOPE of the base and a stray particle relocated: grouping can decline to merge but cannot keep the words apart, because roles re-assemble them and two adjacent same-role pieces are one part at the field level. "Juan Smith van der" becomes middle 'Smith van', family 'der' — a base of 'der' rather than 'van der', and a middle name nobody wrote. Keeping the run whole in one part is what gives the base its full extent. The split reading needs the leftover distribution to know these are separate units, which is mechanisms.md#UNIT-PARTITION's problem.

### R3 — initials honor the fold

R3's earlier history is under `decisions.md#R2`, which this entry does not repeat: the #461 backout and the unsettled given-group conjunction question are recorded there because they were decided alongside R2's all-particle clause. This entry is R3's own, and R3's `history:` points here.

- 2026-08-30 #408 — DONE: `initials()` orders folded words first, as every other view of the same parse already did. O3's fold and P6's attachment both TAG rather than move a token (mechanisms.md#FOLDED_TAG — spans cannot reorder), and `_types._text_for` partitions on that tag for every role it renders, so the `family` field reads `van der` where the tokens sit `der ... van`. `initials()` walked `tokens_for(role)` in written order and never asked, so the two views disagreed about one parse: `parse("der, y van")` gave family `van der` and initials `y. d. v.`, and it gives `y. v. d.` now. This is mechanisms.md#RENDER-HONORS-THE-PARSE's second shape, filed as its worked instance, so the design is not re-argued here — only what the measurement added to it.
  WHAT THE MEASUREMENT ADDED, and it reframes the issue from a consistency fix to a parity restoration. v1 had this feature as `CONSTANTS.middle_name_as_last` (`_config_shim` maps it to `Policy.middle_as_family`) and had NO general `name_order`, so only the two default-order policies have a v1 reference at all. Measured on the released 1.4.0 wheel over the 1094-name deduped corpus, from a script run outside the worktree that asserts `nameparser.__version__` first: at the default policy 1 name moves and agrees with 1.4.0 neither before nor after; under `middle_as_family` 71 move, of which **54 come into exact 1.4.0 agreement and NOT ONE moves away from it**. Worked pair: `Doe, Dr. John A.` gives `J. A. D.` at 1.4.0, gave `J. D. A.` here, and gives `J. A. D.` again; `Brundridge, Contessa A` likewise `C. A. B.` / `C. B. A.` / `C. A. B.`. The facade had been giving the folded answer all along — `HumanName.initials()` reads `first_list`/`middle_list`/`last_list`, and `_facade._list_for` prepends the carriers exactly as `_text_for` does — so the core view was simultaneously out of step with the field beside it, with the facade above it, and with 1.4.0 behind it. A view that stopped honoring a record had been reproducing a v1 bug v1 did not have.
  THE 17 THAT AGREE WITH 1.4.0 NEITHER WAY, recorded because the obvious explanation is wrong — and the FIRST REPLACEMENT for it was wrong the same way, corrected by this branch's docs review before merge. It is not that their parse already differs: only 2 of the 17 parse differently from 1.4.0 under this policy, `Smith, John V.` (#432's dotted numeral) and `남궁민수 지훈` (the unspaced-hangul surname split and the script order, rules.md#W1). The other 15 have SEVEN BYTE-IDENTICAL FIELDS at 1.4.0 and here — `der, y van` INCLUDED, which an earlier draft of this bullet filed with the two above by carrying over a measurement taken at the OTHER policy, where its parse does differ. Of those 15, 14 differ from 1.4.0 only in v1's initials GRANULARITY — v1 initials one group per `*_list` element, so a run its parse joined into one element (`Velasquez y Garcia`, `abdul salam`) yields `V G.` where 2.x, one token per word, yields `V. G.` — and strip the periods and spaces and all 14 come into 1.4.0's letter ORDER after this change, none having been in it before (`JVGQ` → `JQVG`). The 15th is `der, y van`, and it is worth naming rather than lumping: its fields match 1.4.0 exactly and its initials do not, because 1.4.0 contributes NOTHING for a family that is all particles (`van der` → `y.`) where 2.x contributes its words. That is R3's own adjudicated clause and decisions.md#R2's territory — R3 carries `"Juan van der" → initials="J. v. d."` against 1.4.0's `J.` — so this one name is a divergence this document has already decided in favor of, not an unexplained residue. So the parity claim is stronger than "54 of 71 and the rest unexplained": 68 of the 71 come into 1.4.0's ordering, 54 of them exactly, one more is R2's decided divergence, and 2 have a parse difference that predates this change.
  BLAST RADIUS by policy, over the same corpus × three `name_order` values × `middle_as_family` off/on (6564 parses, 660 move). Default order: 1 / 71. FAMILY_FIRST: 1 / 294. FAMILY_FIRST_GIVEN_LAST: 1 / 292. STATE THE POPULATION BESIDE THE COUNT, per this file's own rule: the shape an order change can be observed in is a contributing folded token standing AFTER a contributing unfolded one of the same role, and at the default order the corpus holds exactly 1 such name without `middle_as_family` and 72 with it. So the movers are 1 of 1 and 71 of 72 — a near-total hit rate over a corpus that carries one instance of P6's half, not a blast radius near zero. (The one non-mover is `Chang, Andy C I`, where the folded `C` and the family `Chang` share a letter and the reorder is invisible in initials.) Nine corpus names carry the tag at all at the default order; the other eight have a single contributing family token, which makes the partition the identity. The `middle_as_family` half is where the reach is, which follows from the producers: O3 folds every middle word, while P6's attachment moves initials only where the attached run is READMITTED by the unjoined mark — a family that is all particles — so it reaches one corpus name in each order and the same one. RECOMPUTE, and the COMPARATOR is the half that has to be stated, because the obvious phrasing of it measures nothing: parse the four `tools/differential/corpus*.jsonl` files deduped under each of the six policies and compare `initials()` against THE PRE-CHANGE RENDERING — the same contributing tokens left in WRITTEN order. What this sentence said until 2026-08-30 was "against a folded-first partition of `tokens_for(role)`", which is what `initials()` now IS: that recipe compares the shipped implementation with itself and yields 0 differing parses, the inert-measurement shape (mechanisms.md's Verification shapes section) written into the very sentence meant to prevent it, and the only recompute path the repository offered for docs/release_log.rst's counts as well. CONTRIBUTING is the other half of the recipe and it comes FIRST: apply this view's own skip filter — outside the given group a token tagged `particle` or `conjunction` contributes nothing unless it also carries UNJOINED_TAG — and compare the two orders only over what survives it. Leave the filter out and every skipped word reads as a difference instead: 1546 rather than 660 against written order, and 1018 rather than 0 against the folded partition. Re-measured this way on 2026-08-30 and reproducing the counts above: 660 of 6564 parses, 1 / 71 at the default order, 1 / 294 and 1 / 292 under the two family-first orders.
  THE VERIFICATION LIMIT, and it is decisions.md#R4's, restated because a plausible reading of "one name moves at the default policy" is that the ledger owes it a rule. It does not, and could not: `tools/differential/compare.py` compares the seven role fields plus `_ambiguities`, and `initials()` is not among them — the same blindness recorded for `capitalized()` under R4. Measured rather than reasoned: the gate was run at all three baselines on the pre-change tree (exported with `git archive HEAD` into a scratch directory, so nothing in the working tree moved) and on the changed one, and the two outputs are byte-identical apart from the tree path and the baseline-worker temp path — 1094 corpus names, 229 / 194 / 102 intentional diffs, `unexplained: 0`, and 67 / 58 / 51 headings with identical per-heading counts, at 1.4.0 / 2.0.0 / 2.1.0. NO LEDGER RULE WAS ADDED, and none was needed. What stands in its place is `rules.md#R3`'s example line and `tests/v2/test_render.py`, both mutation-checked.
  WHY R3 GAINED A SENTENCE rather than leaning on R1. R1 already says folded family words "render before the rest of the family wherever they stood in the string" — but its subject is "Every FIELD", and `initials()` is not a field: R3 calls it "this view", and it takes a format spec and two separators rather than being read as an attribute. So R1 does not reach it by its own words, and #408 is precisely what leaving that to inference costs. R3's new clause states the order in R3's own voice, carries the O3 example line (`"Hassan, Mohamad Ahmad Ali"` under `middle_as_family` → `initials="M. A. A. H."`, reusing R1's own input so `corpus_rules.jsonl` is unchanged at 241 names), and R1 and R3 now name each other in `interacts:` — the field says which order, the view says it follows the field. P6's half gets no example line, for the same reason R1 carries none: the only input that witnesses it is `der, y van`, whose PARSE is itself contested, so pinning its initials normatively would pin a value another open question can move. It is pinned in the unit test instead, which is where today's-behavior pins belong.
  THE PARTITION IS PER ROLE, not scoped to FAMILY, and this is a deliberate choice rather than a copied line. Both producers re-role to FAMILY, so FAMILY is the only role a parse can put the tag on today and the GIVEN and MIDDLE arms are unreachable. They are there because `_text_for` partitions for every role it renders and the two views must not diverge again — the same uniformity `_post_rules`' own UNJOINED_TAG loop takes for its three roles, "for uniformity with the rule, not because all three are observable". A producer that ever folded into another part would otherwise reopen #408 there with nothing to fail. `test_initials_folds_in_every_role_it_renders` pins it from a hand-built name, since no input string can. Mutation-checked four ways — scoping the partition to FAMILY, and skipping it for GIVEN, for MIDDLE, for FAMILY — and each fails that test. It took THREE drafts of the fixture to make that true, and the two misses are the same miss one role apart. The first carried two GIVEN tokens and ONE MIDDLE: a one-element partition is the identity, so the MIDDLE arm was asserted and unpinned, and skipping the partition for MIDDLE passed the entire suite. The second fixed MIDDLE and carried NO FAMILY token at all, in a test named for every role it renders — so skipping the partition for FAMILY passed this test, and was caught only by its siblings and by R3's example line, neither of which is about per-role application. A zero-element group is the identity too, and reads even less like a gap than a one-element one, which is why the second miss survived a review that had just named the first. Two tokens per role is what closes it, and the general form is carried as mechanisms.md#TWO-ELEMENT-GROUPS rather than left here: a test written to pin a partition, a sort or a dedup needs at least two elements in every group it claims to cover, or the claim rides on a no-op.

### R4 — case repair reads the unjoined mark

- 2026-08-29 #407 — DECIDED: case repair consults the mark #404 introduced, and consults it for the WHOLE PART rather than for a particle standing alone. Until now three views read it and the fourth did not, which is how `parse("ANH DO")` came to report family_base 'DO' and initials 'A. D.' while `capitalized()` returned 'Anh do' — the same word treated as an ordinary name word by three readers and as a particle by the fourth. The scope question is the only real choice here, and standing-alone loses on the first Vietnamese name that tests it: `anh van do` has family `van do`, two particle-vocabulary words and neither of them alone, so a rule keyed on aloneness would capitalize `Anh Do` and leave `Anh van do`, making the same surname read differently for having a given-name word in front of it. rules.md#R2 states the test as "Every word", not "standing alone", and this is the case that shows why it is stated that way. One predicate, four views.
- 2026-08-29 — this is a DELIBERATE DEVIATION from 1.4.0, not a parity restoration, said plainly because the surrounding branch work is mostly restorations and a reader will otherwise assume this is one. Measured on the released 1.4.0 wheel: `HumanName('ANH DO').capitalize()` leaves `'Anh do'` and `HumanName('anh van do').capitalize()` leaves `'Anh van do'`; both are `'Anh Do'` and `'Anh Van Do'` on this branch. There was nothing to restore — v1 lowercased on vocabulary membership alone and had no notion of a particle with nothing to join, the mark being a 2.x invention.
- 2026-08-29 — the ACCEPTED COST, agreed before the change rather than discovered after it: a degenerate family that is nothing but particles capitalizes too, so `juan van der` renders `Juan Van Der` where 1.4.0 gave `Juan van der`. This is the same trade rules.md#R2 already took for `family_base` — those words ARE the base, so they are name words, and a parser that called them name words for sorting and particles for display would be the incoherent option. Blast radius, measured by running both predicates in one process, and stated over BOTH populations because the neighbouring bullets in this entry measure over 1094 and a bare "4 and 23" would read as measured there too. Over the 1090 names the corpora carried before this change: 4 names move under a plain `capitalized()` — `ANH DO`, `vai la`, `van ma van`, and `der, y van`, which becomes `y Van Der` and keeps its lowercase conjunction — and 23 under `force=True`, the extra 19 being mixed-case inputs the gate holds back (rules.md#R5). Over today's 1094 it is 5 and 24: this change's own R4 example rows added `anh van do`, and that name is itself a mover.
- 2026-08-29 — THE VERIFICATION LIMIT, which matters more than the change. `tools/differential/compare.py` compares the seven role fields plus `_ambiguities` and nothing else, so `capitalized()` is not a compared field and NO ledger run at any baseline can see this deviation: the corpus counts and the three intentional-diff totals hold across it precisely because the harness is blind to it, and a green run is evidence about roles, not about case. mechanisms.md#FACADE-CONTRACT promises that v1-visible changes are "release-log-classified fixes, each verified in the differential ledger". The first half is kept — docs/release_log.rst carries the 2.2.0 bullet — and the SECOND HALF CANNOT BE, for this change or for any other that moves only case. Standing in its place: rules.md#R4's own example rows (`ANH DO`, `anh van do`, and the `juan de la vega` boundary), rules.md#R5's, and the v1-facade tests in tests/test_capitalization.py. The ones that pin the fix are mutation-checked against deletion of the tag consult; the `juan de la vega` control passes that mutation BY DESIGN, which is what makes it a control rather than a witness. Read the silence as a SCOPE and not as a gap: the harness compares parses, case repair is a render view, and nothing is owed on the tracker for it — the next bullet is why that is a decision rather than a deferral.
- 2026-08-29 — WHY CASE IS NOT A COMPARED SURFACE, settled here rather than left open, because "add a column" is the obvious answer to the bullet above and it is the wrong instrument. What the harness is FOR is parsing behavior — tools/differential/README.md says to run it "when touching parsing behavior, and before cutting a release" — and what earns it its two-process cost is that a role is EMERGENT: the eight stages fold over one name and each can move what the next sees, so the reach of a vocabulary or rule edit is not derivable by reading the diff and has to be measured over a corpus instead. Case repair is the opposite kind of thing. `_render._cap_word` is a pure function of `(word, role, tags, lexicon)` with four branches — the particle/conjunction lowercasing, the exceptions map, the Mac/Mc convention, and plain `capitalize` — sitting behind R5's single gate, so an edit to it is legible: you can see what you touched. The measurement agrees with the reasoning. Over the 1094 corpus names the 1.4.0 and 2.1.0 wheels ALREADY disagree on `capitalize(force=True)` for 139 of them (recompute by running `HumanName(n).capitalize(force=True)` under each wheel over the four corpus files deduped, and diffing the two maps; the count was the same over the 1090 names the corpora carried before this change). Nearly every one is an echo of a parse diff the field comparison already classifies — a token that changed role capitalizes differently — so a column would re-report the ledger's own explained diffs in a vocabulary its rules cannot match, and would arrive pre-failed by 139 entries. Isolating the residue means comparing case only where the fields agree, a diff-of-diffs, to reach a class that a unit test reaches directly. So the compared surfaces stay the seven roles plus `_ambiguities`, by design and not by omission, and case is pinned where case belongs: the `rules.md#R4` and `#R5` example rows, and tests/test_capitalization.py.
- 2026-08-29 — DEBT DISCHARGED, the obligation the last bullet of decisions.md#R5 left this commit; recorded here so the pointer resolves rather than dangling. The FALSEHOOD: R4 promised repair "vocabulary exceptions (McDonald) included" flatly, which `str(parse('Juan Mcdonald').capitalized())` — `'Juan Mcdonald'` — falsifies, the gate refusing before any vocabulary is consulted. The statement now scopes that promise to where repair acts at all and hands the where to R5. A SECOND error in the same eight words turned up while rewriting them, and is worth recording because it had survived unread for as long as the first: McDonald is not a vocabulary exception. The shipped exceptions are five entries — II, III, IV, M.D., Ph.D. (recompute with `Lexicon.default().capitalization_exceptions_map`) — and `Mc`/`Mac` is a separate convention applied to any name shaped like one, so the old parenthetical illustrated the promise with the one mechanism it was not about. The statement now names an exception the vocabulary actually records (Ph.D.) and the Mac/Mc convention separately. The AMBIGUITY: "an already-correct name comes back unchanged" is true read as idempotence and reads as false under bearer-correctness, and the repair was to say WHICH is meant — a name already written the way repair would write it comes back unchanged — plus one clause refusing the inference a reader might otherwise draw, that a spelling its bearer chose is not spared for having been chosen. That clause is deliberately a REFUSAL and not a carve-out: sparing deliberately single-cased names is behavior the parser does not have and that R5's rationale declines on the record, so `str(parse('bell hooks').capitalized())` is still `'Bell Hooks'`. R4's boundary moved too: the old `"Juan McDonald" → capitalized="Juan McDonald"` row was inert — it passes with R5's gate deleted — so it became a `capitalized_forced=` row, which asserts what repair APPLIES rather than what the gate keeps and fails when the Mac/Mc convention is removed (measured by neutering it in one process: `'Juan Mcdonald'`). The boundary is now `juan de la vega`, a working particle run R4's new clause deliberately does not reach.

- 2026-08-29 — the CONJUNCTION CARVE-OUT, stated in rules.md#R4 rather than left to a code comment, and the reason the comment gave was false. `_cap_word`'s conjunction conjunct is deliberately NOT gated on the unjoined mark, and the behavior is right: R3 already carries the carve-out for initials ("A CONJUNCTION never initials, so a base that is one contributes nothing even then"), and R4 was the half of the pair that carried it nowhere. What the comment claimed instead was an impossibility — `particles` and `conjunctions` are disjoint, so no conjunction token can ever carry the mark — and disjointness is a property of the SHIPPED vocabulary while both sets are public, configurable API. Measured: under `Lexicon.default().add(particles={'y'})`, `anh y van` parses to an all-particle family whose `y` carries `conjunction`, `particle` and the mark at once, and repairs to `Anh y Van`; gating the conjunct too, in one process, gives `Anh Y Van`. So the clause decides something and is not a no-op. It gets NO example row, and that is a limit of the document rather than a choice: the runner parses with the default vocabulary, over which `particles ∩ conjunctions` is empty in the default lexicon and in all four locale packs, so no input string can witness it. `der, y van` is the nearest thing and does not witness it either — the `y` there is a GIVEN word standing beside the all-particle family, not inside it (tests/test_capitalization.py says so at the row).

- 2026-08-29 — the REPLACE/REVISE BOUNDARY, raised by the review of this branch as a gap needing a systematic fix and corrected by Derek to what it actually is: the documented boundary, with a supported path across it. `_cap_word` keys the particle TEST on lexicon membership but the REPAIR on the unjoined mark, and the mark is a parse product — so `parse('de la').capitalized(force=True)` is `'De La'` while `base.replace(family='de la').capitalized(force=True)` is `'de la'`, `replace()` splicing raw text into a field without classifying it. `Parser.revise()` is the method that classifies it, and its docstring already promised exactly this: each value gets a full sub-parse "so the stable tags survive and the tag-driven views ... behave as if the text had been parsed". Measured: `Parser().revise(base, family='de la').capitalized(force=True)` is `'De La'`. So there is nothing to fix — rules.md#R4 carries it as an Accepted boundary naming `revise()`, not as a deviation, and `revise()`'s enumeration of the tag-driven views gains `capitalized()`, which #407 made the fourth. (#458 would additionally make `replace()` agree, by keying the test on the `particle` tag rather than re-deriving membership from the word — the same shape it already names for `conjunction`/`initial`. That is a convenience, not the resolution.) It is a limit and not a regression either way: 2.1.0 gives `'de la'` on the spliced path too, so only the parsed path moved.

- 2026-08-29 #458 — DONE: case repair's conjunction test reads the `conjunction` TAG instead of re-deriving the word's class at render time, so a view honors the decision the parse recorded (mechanisms.md#VOCAB-TAGS) rather than taking it again. The two copies of the question were not the same question, which is the reason this was worth doing over a corpus that cannot see it: `_classify.py` asks `is_initial(token.text)` — the shape test ANDed with a repertoire test since #320 — while `_render.py` asked a bare pattern, shape only, over each WORD of a token's text rather than the token. It does NOT retire the hand-sync obligation the module carried in a comment ("keep in sync with `nameparser/_pipeline/_vocab.py` by hand"), and an earlier draft of this bullet said it did: the tag read has nothing to keep in sync, but `_render._INITIAL` was deleted and then restored in the same PR, the fallback in the bullet below giving it a reader again. The obligation is narrower and sharper than before — the two copies no longer answer the same question about the same token, so what has to hold is that the fallback answers as `_vocab` would — and test_regex_sync is what holds it. MEASURED, re-derived on this branch rather than taken from the issue: 0 of the 1094 corpus names move under `capitalized()` or `capitalized(force=True)`, and 0 move with each name also re-run uppercased and lowercased (6564 name/variant/lexicon rows = 1094 x 3 spellings x 2 lexicons, the second lexicon being `Lexicon.default().add(particles={'y'})`, and 13128 `capitalized` calls over them); the R5 force-versus-case counts recorded above hold — 62 upper / 16 lower through the facade, 63 upper / 18 lower through the core. Movement EXISTS outside that population, and the issue's "no output moves" is a claim about the corpus only: the old predicate re-decided per word, so `juan e-f smith` capitalized to `Juan e-F Smith` — the Italian conjunction inside a hyphenated middle name — and now gives `Juan E-F Smith`, agreeing with the uppercase spelling it always gave. An earlier draft of this change moved untagged tokens the same way — a family `replace()` splices in as `de y` repairing to `de Y` — and that was a REGRESSION, caught in review before it shipped and fixed in the bullet below; read this bullet with that one. NOT DONE, and the parenthetical in the boundary bullet above should be read as still open: the PARTICLE conjunct still keys on lexicon membership, so `replace()` does not agree there. Making it read the `particle` tag is a second change with its own blast radius — it moves a boundary rules.md#R4 states in prose — and #458 as filed does not ask for it.

- 2026-08-29 (#458 review) — THE FALLBACK FOR TEXT THE PARSE NEVER SAW, and why it is scoped to one of the two questions `_cap_word` asks. Reading the tag and nothing else regressed every ASSIGNED field holding a conjunction, because a value `replace()` splices in is never classified and so carries no tag to read. Measured on the released 1.4.0 and 2.1.0 wheels, all agreeing and all broken by the tag-only reading: `h.last = 'velasquez y garcia'` then `capitalize(force=True)` gives `John Velasquez y Garcia`, `h.middle = 'e.'` gives `John E. Smith`, `h.last = 'smith-y'` gives `John Smith-y`, `h.first = 'y'` gives `y Smith`. That is the canonical Spanish surname and the shape `y` is in the vocabulary FOR, so an Accepted clause was not available: it had to be fixed. RULE: a token carrying `UNCLASSIFIED_TAG` was never read, so there is no decision to honor and the view falls back to the vocabulary. Note what the discriminator is NOT, in two directions, the second of which took a second review round to find. It is not UNTAGGEDNESS: an ordinary parsed name word carries no tags either (`velasquez` parses to `tags=[]`), so that would have swept in the whole parsed corpus. And it is not the absent SPAN, which is what the first fix used. Span-less means SYNTHETIC, a wider set than unclassified: `Parser.revise()` builds span-less tokens too, from a full sub-parse whose tags it keeps deliberately, and its docstring promises the tag-driven views "behave as if the text had been parsed" — so keying the fallback on the span overrode exactly the tags `revise()` exists to preserve. Measured on the branch that did it: `revise(name, middle='e-f').capitalized(force=True)` gave `e-F` where `parse('john e-f smith')` gave `E-F`, and under `Parser(lexicon=Lexicon())` a revised family `de la vega` initialed `j. v.` where the parse initialed `j. d. l. v.`. So the mark is stamped by the two producers of genuinely unclassified text — `ParsedName.replace()`, and the facade's v1 pickle load, which rebuilds a name from `*_list` strings and no tags — and a HAND-BUILT span-less token is not marked, so it takes the tag path like everything else in the library. That is the tag-driven semantics every other view already has, and it is a change from the span reading in exactly one place: a hand-built span-less token whose text is CONJUNCTION vocabulary now capitalizes rather than lowercasing (measured: a hand-built family `velasquez y garcia` repairs to `Velasquez Y Garcia` untagged, `Velasquez y Garcia` with either the `conjunction` tag or the mark). The PARTICLE conjunct is unaffected either way — it keys on lexicon membership rather than on the mark, which is the open item this bullet's neighbours record — so a hand-built `de la Vega` is unchanged. SCOPED TO THE PER-WORD QUESTION, and this is the durable half. *Is this word a conjunction rather than an initial* is a property of the word and its spelling: an unread token has no ambiguity the parse would have resolved differently, so the vocabulary gives the answer the parser would have given, v1's initial carve-out included — which is why `_render._INITIAL` came back and why test_regex_sync's two-copy assertion matters MORE now, the fallback being right only while it answers as `_vocab` would. *Is this particle acting as a particle here* is a property of the whole PART, and answering it needs a reading on every word of the part, which is exactly what a spliced field lacks. `_cap_word` is handed the whole token's tags — it is not blind to the part because it walks one word, and an earlier draft of this bullet said it was — but with no tag on any word of the part there is nothing to re-derive the part answer FROM, so the fallback here is the word question alone and the particle half falls through to plain particle treatment. Measured cost of the wide reading IN CASE REPAIR, so nobody re-derives it: `replace(family='de la')` gives `de la` today and a parse gives `De La`; widening case repair's fallback would make them agree and would thereby REVERSE the replace/revise boundary recorded two bullets above, with `revise()` as its documented crossing. Do not widen it without reopening the decision. Verified after the fix: `replace(family='velasquez y garcia').capitalized(force=True)` is `Velasquez y Garcia`, `replace(family='de la').capitalized(force=True)` is `de la`, and the parsed and `revise()` paths are unchanged.

- 2026-08-29 (#458 review, then the review of PR #463) — the SAME FALLBACK in `initials()`: WRITTEN, MEASURED AND DROPPED before merge. The defect it aimed at is real and older than #458 — `replace(family='velasquez y garcia').initials()` gives `j. v. y. g.` where the parse, the facade and 1.4.0 all give `j. v. g.` (the facade and 1.4.0 measured through the TWIN of that splice — `h = HumanName('juan smith')` then `h.last = 'velasquez y garcia'`, the assigned construction this bullet is about, keeping `first`), and `replace(family='de la vega')` gives `j. d. l. v.` for `j. v.` — and it is unfixed again. What killed the fix is the parameter list: `initials()` takes no lexicon, so the fallback had to reach for `Lexicon.default()` and GUESS. `capitalized(lexicon=...)` is handed the caller's vocabulary and so guesses nothing, which is why its fallback stays. Measured cost of the guess, and the reason a merely-imperfect fallback was not good enough: under `Parser(lexicon=Lexicon.default().add(particles={'y'}))`, `parse('Juan de y').initials()` is `J. d. y.` and `parse('Juan Perez').replace(family='de y').initials()` was `J. d. y.` before the fallback and `J.` after it — an entire field's initials gone, because the default vocabulary reads `de y` as particles-plus-conjunction where the caller's reads it as an all-particle part. Guessing the wrong vocabulary for a name parsed under a custom one is worse than answering from tags alone, and `initials()` cannot be told better. The missing crossing is the whole of it: `Parser` carries `capitalized`, `matches` and `revise` as its custom-lexicon crossings and no `initials`, so there is no supported way to hand this view the parse's vocabulary. #464 is filed for that; when it lands, the fallback becomes answerable rather than a guess and this decision is worth reopening. ACCEPTED until then, and stated so a reader does not re-derive it as a bug: `replace(family='de la vega').initials()` is `j. d. l. v.` where the facade and a parse both give `j. v.`. The reach is the v2 core only — `HumanName.initials_list()` computes from the field STRINGS with its own lexicon (`_facade._initials_lists`) and gives `['j', 'v', 'g']` at 1.4.0, 2.1.0 and here, under that same assignment. That is why the facade cannot be used as evidence that the core is fine. Do NOT read `_initials_lists` as a v1 parity REFERENCE, though, which is how an earlier wording of this bullet labelled it: #462 is open on a parity break in that exact path, and it is a break inside the facade rather than upstream of it. `HumanName('Scott E. Werner')` partitions to `first='Scott'`, `middle='E.'`, `last='Werner'` at 1.4.0, at 2.1.0 and here — byte-identical fields — and `initials()` on them is `S. E. W.` at 1.4.0 and `S. W.` at 2.1.0 and here, the facade reading the middle `E` as the conjunction; assigning those three fields by hand reproduces each version's answer, so no parse difference is involved. The parity label was therefore doing work its own measurement cannot support, and only the per-name measurements are load-bearing here. MEASURE THIS BULLET WITH THE ASSIGNED CONSTRUCTION, and name the construction beside each value: the review of PR #463 measured `HumanName('juan velasquez y garcia')` — the PARSED form — against sentences about the assigned one, and 'corrected' two true claims into false ones. The two constructions agree from 2.0 on and DISAGREE at 1.4.0, where the parsed form gives `['j', 'v g']` and `j. v g.`. The `last` string is byte-identical either way; `initials_list()` reads `last_list`, which the 1.4.0 parse leaves as `['velasquez y garcia']` — the conjunction run joined into one element — where assignment re-splits it to `['velasquez', 'y', 'garcia']`. So `computes from the field STRINGS` is exact for 2.x and true of the ASSIGNED form only at 1.4.0. Two things learned in the attempt are worth keeping though the code is gone. `_types._remarked` is not the bug and needs no change: it recomputes the mark from TAGS after every edit, which is right for a record of what the parse decided and simply silent about text nobody classified. And any future fallback must be scoped per ROLE, not per name — a role the parse classified whole is decided by its tags however the other roles were built — a distinction that passes every other test in the suite and needs a hand-built name to witness.

- 2026-08-29 — WHY THE BOUNDARY WENT UNNOTICED UNTIL #407, which is where a future reader should look for it. For an ALL-PARTICLE part the other three tag-driven views give the same answer through `replace()` and `revise()` alike: measured over `de la`, `van der`, `do`, `de` and `van de la`, all five agree on `family_particles=''`, on a `family_base` holding the whole part, and on initials from every word. They converge because an UNTAGGED part and a MARKED all-particle part reach the same place by different routes — untagged, no word is recognized as a particle; marked, none is ACTING as one — and all three views only ask which words are particles. Case repair is the one view that asks a second question, since it must also decide whether to lowercase, so it is where the two routes first come apart. The mirror case confirms the reading: on a MIXED part the convergence is the other way round — `de la vega` and `van der berg` diverge in all three views between `replace()` and `revise()` (`replace()` reports particles `''` and base `'de la vega'` where `revise()` reports `'de la'` and `'vega'`) and AGREE on case repair, R4's all-particle clause not reaching them. So before #407 the distinction was invisible on exactly the shape the clause is about, and visible only on shapes the clause does not govern.

### R5 — the case-repair gate

- 2026-08-29 (#407 arc) — EXTRACTION, not a decision: the parser is untouched. The two halves of R5 have separate provenance, and conflating them is easy enough that the first draft of this entry did. The REFUSAL — repair skips any name already carrying more than one case — is older than the git history: `git log -S "name == name.upper() or name == name.lower()" --reverse` bottoms out at 45a1539 (2011-02-03), the initial import from svn, where `capitalize()` already opens with that guard and a bare `return`. (A path-filtered search answers 280895b instead, the same-day commit that moved the module into `nameparser/`; the code did not change there.) The OVERRIDE is bf1e0a5, 2016-06-02, which did not add the refusal but wrapped it — `if not (name == name.upper() or ...)` became `if not force and not (...)` — and shipped in 0.4.0 (June 2, 2016; its own release-log line is docs/release_log.rst under that heading). So R5's statement as a whole holds from 0.4.0 on. rules.md had never said any of it, though bf1e0a5's diff shows the API docstring already did: "It will not adjust the case of names entered in mixed case" was there before that commit edited around it. The gap was rules.md's alone. Evidence, measured on the released 1.4.0 wheel — the last v1 release and one of the differential baselines, so a natural thing to measure against and not a release that introduced anything here — and re-measured on this branch today, facade and core agreeing: `HumanName('Shirley Maclaine').capitalize()` leaves `'Shirley Maclaine'` — mixed case, wrong, and kept — while the same name under `force=True` gives `'Shirley MacLaine'`; `HumanName('Juan McDonald').capitalize()` leaves `'Juan McDonald'`. rules.md's own preamble classifies behavior in this position as "pinned-but-undocumented — an extraction gap to close, not a specification", which is why the fix lands in the document rather than in `_render.py`.
- 2026-08-29 — why the gap survived, and what the new boundary buys. R4 promised that "an already-correct name comes back unchanged", which is idempotence: true of everything the gate returns, and silent about the gate. The two readings were indistinguishable through R4's boundary, because `"Juan McDonald"` comes back unchanged under `force=True` as well — an already-correct input cannot witness a rule whose subject is what gets kept REGARDLESS of correctness. `Shirley Maclaine` separates them, so R5's block pins it twice: kept as written on a plain `capitalized=` line, and repaired on the forced line. Under idempotence the plain line would have to fail. The BOUNDARY sits on the forced line, which took a correction to get right — the first draft marked the refusal, and the refusal is R5's effect, not its absence. R5's own stated exception is repair-regardless, so that is the non-firing case (rules.md's preamble: the boundary is "usually the rule's OWN stated exception").
- 2026-08-29 — WHY R5'S STATEMENT WAS REWORDED RATHER THAN GIVEN A THIRD EXAMPLE, recorded at length because the wrong turn was taken twice and the reasoning is reusable. R5 first said a mixed-case name is returned untouched "whether or not its casing is correct". That phrasing reads as a disjunction with two branches, and invites the question of which example witnesses the already-correct one. Nothing can: for a mixed-case name that repair would not alter, kept and repaired are the SAME STRING by construction, so no example line distinguishes a parser with the gate from one without it. The unwitnessable branch is a property of the claim, not a gap in our choice of names — and the bullet above had already said so ("an already-correct input cannot witness a rule whose subject is what gets kept REGARDLESS of correctness") before two review rounds pushed past it. Two rows were tried and both withdrawn. `"Juan McDonald" → capitalized="Juan McDonald"` was INERT (mechanisms.md's inert-measurement class): it passes with R5's gate deleted, which is the whole failure shape that class names. `"Vincent Van Gogh" → capitalized="Vincent Van Gogh"` did discriminate — measured by comparing the shipped call against gate-off behavior, which the forced call reproduces exactly, since deleting the gate is what `force` already does: shipped `'Vincent Van Gogh'`, gate-off `'Vincent van Gogh'`, the particle rule lowercasing `Van` the moment repair runs — but it was REDUNDANT, not complementary. It asserts the same proposition as the `Shirley Maclaine` row above it: repair would change this name, and the gate keeps it anyway. The two differ only in which repair rule would have fired, and which rule repair applies is R4's subject, not R5's. And the frame it was chosen under was itself wrong, which is the more useful half of the lesson (reframed 2026-08-29 on Derek's correction, while R4 was being reworked). The row was picked to be bearer-correct — a name whose casing the bearer would endorse — and that frame drags the document into per-name arguments about whose spelling is right: this one needed `Vincent van Gogh` to be the correct spelling, which P6's own examples contradict, and the tussenvoegsel convention behind them is amended later on this branch anyway. CORRECTNESS DOES NOT ENTER INTO IT. Mixed case is the writer making an explicit choice, and repair defers to that choice rather than judging it — a name kept is not a name endorsed. Read that way the withdrawn row needed no claim about Dutch orthography at all, and the reason it needed one is exactly that the frame was wrong. The fix was to stop claiming the unwitnessable branch: the statement now says a mixed-case name is kept and that whether its casing is right does not enter into the decision, which `Shirley Maclaine` — casing wrong, kept — witnesses whole. GENERAL LESSON, and the reason this is long: when an example cannot be found for half a rule, suspect the STATEMENT before suspecting the example set. A phrasing that promises more branches than the behavior has will absorb inert examples indefinitely, each one looking like progress. Caution for the commit that reworks R4 and will choose its own mixed-case rows: avoid a name whose family base is wholly particle vocabulary (`Anh Van Do`, base `Van Do`), because the #407 work changes how those capitalize and would neuter such a row silently; `gogh` and `vega` are in neither `particles` nor `particles_ambiguous`, `van` and `do` are in both.
- 2026-08-29 — the override has TWO routes, and the rule states neither, by design. Per-call is the obvious one. The second is a v1 Constants attribute the facade still honors, `force_mixed_case_capitalization` (nameparser/_facade.py resolves it when no per-call value is given; docs/release_log.rst records it as "still honored through the facade"). Measured today: with that attribute set True, `HumanName('Shirley Maclaine')` then a bare `.capitalize()` — no argument at all — LEAVES `'Shirley MacLaine'` (the v1 call mutates in place and returns None, so the name is read back with `str()`). An earlier draft of R5 said "only an explicit request to repair regardless overrides that", which that measurement falsifies; the statement now says repair was asked for anyway, without saying by what route (it read `repair regardless of how the name is cased was asked for` until 2026-08-29, when that phrasing turned out to have a second reading -- see the override bullet below), because rules.md is implementation-free by its own preamble.
- 2026-08-29 — `capitalized_forced` is a test-side pseudo-field, not a parser field. The route the EXAMPLES use is the per-call argument to `capitalized()`, and an argument is not a policy, locale or extras gate — the only three things rules.md's grammar admits in an example's annotation slot — so it cannot ride that slot, and the doc runner grew a resolver branch instead. (The facade attribute above is a second route to the same behavior, not a second thing to assert; the core takes the argument only.) The R5 block's example lines are asserted by the suite like any others. They do enlarge one file: `corpus_rules.jsonl` is generated from this document's examples, and `Shirley Maclaine` had not been among them, so regenerating adds it (235 lines, from 234). Whether that moves the DIFFERENTIAL population is a separate question with a per-name answer, and the general answer is that a rules.md example CAN move it: the harness dedupes across corpus files, so a new example name costs a population slot exactly when no other corpus already carries that same string. `Shirley Maclaine` was already in `corpus.jsonl`, so it costs nothing and the population holds at 1090 — measured, not assumed. The row withdrawn above is the counter-case, and worth keeping for it: `Vincent Van Gogh` appears in no other corpus (`corpus.jsonl` and `corpus_issues.jsonl` carry `Vincent van Gogh`, a DIFFERENT string that does not dedupe against it), and while it was in the block the population read 1091. Neither name needed a ledger rule, which is a measurement rather than a consequence of the above: the gate exits 0 with 0 unexplained at all three baselines either way.
- 2026-08-29 — WHAT THE OVERRIDE DOES AND DOES NOT PROMISE, from Derek's framing of R5 and then measured, because the framing implies a property that is ALMOST true and the gap is the useful part. The framing first, and it supersedes the correctness talk elsewhere in this entry: mixed case is the writer making an explicit choice, and repair defers to that choice instead of judging it. Nothing is being called correct or incorrect — a name kept is a name whose writer said something about it, and a name repaired is one whose writer did not. The property that seems to follow is that asking for repair REGARDLESS should ignore the given casing entirely, so one name repairs to one string however it was written. MEASURED over the 1094 corpus names and it does NOT hold. On the v2 core — `parse(n).capitalized(force=True)` against `parse(n.upper()).capitalized()`, rendering all seven roles — the two differ for 63 names, and the lowercase direction for 18. Naming the surface matters here, and BOTH counts move with the surface, which an earlier draft of this bullet got half right: through `HumanName` and `str()`, which is what tests/test_capitalization.py uses, the counts are 62 and 16 — and the two directions are dropped for DIFFERENT reasons, which an earlier draft of this bullet ran together under the first. UPPER: the facade's default render spec omits the maiden name, so `str()` cannot see `Jane van der Berg née y Jones` (the whole of the upper difference, and it is a conjunction inside the maiden name — `maiden` is `y Jones` forced against `Y Jones` uppercased and every other role is byte-identical). LOWER: the two the facade drops are `John van der J. V` and `abdul V Smith`, and the maiden field is EMPTY in both directions for both, so the omitted role explains neither. What hides them is that `str()` CONCATENATES adjacent roles, so a token that crosses a role BOUNDARY and moves nothing else is invisible in the joined string: `John van der J. V` is family `van der J. V` forced against family `van der J.` plus suffix `V` lowercased, and `abdul V Smith` is given `Abdul V` forced against given `Abdul` plus middle `V` lowercased — same seven roles rendered, same string joined. Re-derived 2026-08-29 on this branch, and named per name so the next reader can check the arithmetic: core 63/18, facade 62/16. Two things about those numbers are the opposite of what one would guess. UPPERCASE IS THE WORSE DIRECTION, not the clean one. And the misses are not merely the pipeline's case-sensitivity leaking in: of the 63, only 25 move a role at all, and the other 38 parse byte-identically and diverge inside the repair (25 and 37 through the facade, the missing name being one of the byte-identical ones). The mechanism is v1's initial carve-out — a conjunction is not lowercased where it is written initial-shaped, and initial-shaped means one CAPITAL letter — so uppercasing turns every one-letter conjunction into an initial (`Velasquez y Garcia, Dr. Juan Q.` forced keeps `y`; the same name uppercased then repaired gives `Y`, with a byte-identical partition either side -- the comma form is the one to cite here, the space-written `Dr. Juan Q. Velasquez y Garcia` being a member of the 25 whose roles DO move), and lowercasing turns a middle initial `E` into the Italian conjunction. 1.4.0 does the same (`JUAN Y GARCIA` capitalizes to `Juan Y Garcia`), so this is inherited, and it is recorded here rather than fixed here. Recompute both directions by running the two forms over the four corpus files deduped and diffing.
- 2026-08-29 — and therefore NOT stated in rules.md, which is a deliberate choice rather than an oversight. The document's examples are keyed on input STRINGS, so any statement of the property invites exactly the test that falsifies it — re-case the input, expect the same output — and the counterexamples are already in the corpora. The property is true of the repair given a parse, and rules.md speaks input-to-output; a rule stating it would be over-broad in the one direction a reader would check. What R5's statement says is enough for the promise that IS kept: a mixed-case name is kept unless repair was asked for anyway. That clause was REWORDED for this, and the reword is the whole point rather than a tidy-up. It read `unless repair regardless of how the name is cased was asked for`, which carries two readings -- the intended one, that the request overrides the keeping, and a second one, that the repair disregards the input's casing, which is this property in nearly this bullet's own words. A reader taking the second reading would run the re-casing test predicted above, land on `Velasquez y Garcia, Dr. Juan Q.` (in the corpus today), and conclude the RULE is wrong when only the phrasing was. Nine words, and they asserted the thing the paragraph exists to deny. The property is pinned in tests/test_capitalization.py instead, over names carrying no single-letter word whose class case decides, with `juan y garcia` beside it as the recorded exception. R5's example block gains `"SHIRLEY MACLAINE" → capitalized="Shirley MacLaine"` from this work, and it earns its place on its own ground rather than as half of a convergence pair: it is the only row in the block that fails when the gate is narrowed to lowercase-only, every other row passing that mutation. Measured three ways — gate deleted (passes, so it does not witness the gate's existence), gate narrowed to accept only all-lowercase (FAILS, and alone in the block), Mac/Mc convention deleted (fails, with the other two rows). Until it was added, R5 stated that repair acts on a name written entirely in one case and witnessed only the lowercase half of it. That lowercase half is still `"juan mcdonald"`, which is byte for byte an R4 row as well, and the duplication is deliberate rather than an editing slip: the two rules make different claims about the same line — R4 that the repair honors the Mac/Mc convention, R5 that an all-lowercase name is acted on at all — and dropping it from R5 would leave the gate's lowercase half unwitnessed inside the rule that states the gate. Five other rows already sit under two rules apiece for the same reason (P5/P6 twice, P5/O5, N3/M4, W1/W3).
- 2026-08-29 — DEBT this extraction leaves, named so the next commit inherits an obligation rather than a rediscovery. Pulling the gate out into R5 leaves R4 carrying ONE falsehood and ONE ambiguity — different defects wanting different repairs, and `interacts: R5` carries neither, the field being advisory. FALSE: R4 promises repair "vocabulary exceptions (McDonald) included", but `str(parse('Juan Mcdonald').capitalized())` is `'Juan Mcdonald'` — the gate refuses before any vocabulary is consulted, and only `str(parse('Juan Mcdonald').capitalized(force=True))`, `'Juan McDonald'`, reaches the exception. R4 needs its promise scoped to names the gate admits. AMBIGUOUS, not false: R4's "an already-correct name comes back unchanged" means correct by the repair's own conventions, i.e. idempotence, and under that meaning it is true; a reader hears correct as the bearer writes it, and under THAT meaning `str(parse('bell hooks').capitalized())` — `'Bell Hooks'` — looks like a counterexample. It is not one, because `bell hooks` is not already-correct in R4's sense. What R4 owes is a disambiguation of "correct", NOT a narrowing to spare deliberately single-cased names: that would be new behavior, and R5's own rationale declines it on the ground that single case leaves the repair nothing to read. Also for that commit, and inert as things stand: R4's boundary row `"Juan McDonald" → capitalized="Juan McDonald"` passes with R5's gate deleted, exactly like the R5 row that was withdrawn above; rewriting it to `capitalized_forced=` makes it discriminate for R4's own subject but still witnesses nothing about the already-correct question. This commit adds R5 and touches R4 only on its pointer line, leaving both defects as found rather than half-fixed by a commit whose subject is something else.

### parse-cost — what a parse is allowed to cost

Every number below is a py3.11 measurement of 2026-08-31, recomputable with `uv run python tools/perf/call_count.py` (add `--modules`, `--stages`, or `--against <ref>`). That harness is in the tree BECAUSE of this entry: its first draft took figures across several sessions with throwaway scripts on whichever interpreter was to hand, and published a module table that spliced one interpreter's before-column onto another's after-column. Four of its eight module numbers were wrong, its four stage numbers were 2x (2000-parse totals labelled per 1000, which its own arithmetic contradicted — four stages summing past the whole parse), and it blamed the wrong PR. Quote nothing here without the interpreter beside it.

- 2026-08-31 #475 — the benchmark bounds FUNCTION CALLS per parse, not seconds, as a per-interpreter BAND of ±2%. A loose 5s wall-clock backstop stays over BOTH entry points for what frame counts cannot see: a compiled regex that starts backtracking emits no `call` event, and neither does C-level work. (A quadratic inside a comprehension or generator IS visible — `call` fires once per generator resume.)
  WHY THE CLOCK HAD TO GO. The 1.0s bound failed four times across #466 and #474, always on CI's 3.12 job, always between 1.01s and 1.08s, with master re-running green each time. Three local methods disagreed with CI and with each other: uninstrumented ~1% branch-over-parent (inside the parent's own spread), coverage-instrumented indistinguishable, and the whole benchmark file under `--cov` made the branch FASTER. The same harness measured `origin/master` at 94-96ms and again at 89-92ms twenty minutes later. A check that cannot separate a 1% change from a busy runner does not fail safe; it fails expensively.
  WHY A BAND AND NOT A CEILING, which is the correction the review forced. A ceiling with headroom is ANOTHER threshold that happens to break — the failure this replaced. The first draft set 470 against a 408 baseline, and measured, that catches nothing smaller than +61 calls on 3.11 and +84 on 3.12: it would have missed the very regression it was built for on four of five interpreters. The cycle's +67 arrived across a dozen PRs at roughly five calls each, and no ceiling loose enough to be safe can see five. A ±2% band trips at +5, verified by injection. A DROP trips it too, which is intended: an unexplained fall is as much a signal as a rise.
  PER INTERPRETER, because the count is not machine-independent — 410 on 3.11, 388 on 3.12, 406 on 3.13-3.15 for `parse`. PEP 709 inlined the comprehension frames 3.11 counts and 3.13 added others back. The first draft claimed "the same number on any machine" and took its baseline on 3.11 by luck: had it been taken on 3.12, the 3.11 job would have gone red with nothing in the file explaining why. A version with no row fails loudly with its own number rather than passing unguarded.
  WHAT THE OLD BOUND HAD CAUGHT, which is the substantive half. Measured `--against v2.1.0`: `parse` 343 → 410 and `HumanName` 380 → 447, **+67 calls per parse**; by stage, per 1000 parses, `group` 13.5 → 22.2ms, `classify` 11.6 → 13.5, `assign` 11.1 → 13.0, `post_rules` 5.2 → 8.0. The bound was not too tight. The parser had grown into it, and the clock could not say so with enough confidence to act on.
  WHERE THE CALLS WENT, per parse, v2.1.0 → here: `_group.py` 94 → 84, `_pieces.py` 0 → 51, `_post_rules.py` 16 → 33, `_vocab.py` 26 → 32, `_assign.py` 21 → 18, `_classify.py` 14 → 16.
  AND WHICH PR SPENT THEM, which the first draft got backwards. It named #439's predicate extraction as "the largest contributor" and built a trade-off on it. Measured across its own merge boundary, #439 cost **zero**: 404 → 404, `_group` shedding 51 exactly as `_pieces` gained them. A pure relocation, and `mechanisms.md#ONE-PREDICATE-PER-QUESTION` says as much — the sharing between `group` and `assign` predated the module. The real largest single contributor is **#424 at +23** (381 → 404), the PR that made group's chain and walk mirror assign's; then #404 at +12, with #361, #367 and #434 at +8 each. So the entry's old conclusion — that recovering the cost means reintroducing the drift #439 removed — deterred work on a PR that spent nothing while +23 went unexamined. Recovering it, if ever wanted, starts at #424's peel walks.
  ONE REPAIR WAS TRIED AND REVERTED: collapsing `post_rules`' three separate role-index scans into one pass. It saves **6 calls per parse** on 3.11 (the first draft said one, which is not even reachable — `_idx` is a one-line list comprehension, so each removed call frees two frames). Reverted because it was measured against a noisy timing harness that showed nothing; at 6 calls it is 9% of the cycle's growth and worth reconsidering against the band, which can now see it.
  RAISING OR LOWERING A ROW IS A DECISION, not a maintenance chore. Append here with the interpreter and the harness invocation, as above.


### removed-v1-surface

- empty_attribute_default: removed in 2.0 (#255; deprecated in 1.4 per the bridge discipline). Origin #44 (2016): a DB-NULL convenience whose first answer — `name.title or None` — became the migration path. The in-band-signaling bug that sealed it (#254): the 2016 `.replace('None','')` scrub could not tell interpolated None from name text, so "Nonez Smith" rendered
  "z Smith" — the fix shape, worth keeping as a one-liner, is
  SUBSTITUTE BEFORE FORMAT, never scrub after. Two removal cautions shaped the implementation: tombstone-not-deletion (a plain deleted attribute would make assignment silently accepted-and-ignored, the #241 failure family) and readers-not-just-writers (#44's own thread advised asserting against the attribute, so reads existed downstream). The tombstone and pickle-tolerant load die with the shim. (An earlier 3-0-reevaluations bullet said "left untyped, typeable in 3.0" — promoted from a memory that was already three days stale when written; nothing is left to type.)
- bytes input: removed in 2.0 (#245; decode-first DeprecationWarnings shipped 1.3.0 per the bridge discipline); the shipped TypeError carries the decode hint.
- no_vowels: removed in 2.0 (#268, filed 2026-07-07, closed 2026-07-28) — never consulted by any parser version, ASCII-only; the facade carries no replacement because there was nothing to replace.

### script-table-placement — a recorded reversal

- 2026-07-29 (script-ranges relocation) — REVERSES #271's documented choice ("tables deliberately live in _vocab"): the tables moved to _policy because packs cannot import the pipeline, evidenced by two hand copies with sync tests and a blocked 16–30× compiled-regex optimization (a module-level re.Pattern in a pack would flip its classification, hence the closure-held-pattern convention). Rider: U+3006 〆 joined the HAN span on a justification deliberately beyond UAX #24 — it is Script=Common but appears solely in Japanese surnames (〆木, 〆谷, 〆野).

### 3-0-reevaluations — decisions shaped by the v1 shim

Promoted 2026-08-15 from session memory (Derek's 2026-07-30 ask; promotion approved 2026-08-15). Discipline when appending: mark each entry (A) "would decide differently without the shim" — real 3.0 work — or (B) "cited 1.4 parity but stands on its own", recorded so nobody re-litigates it. Append here whenever a design choice cites 1.4 parity or the shim as a load-bearing reason. Standing discipline for the removals themselves: every removal warns in a RELEASED version first — the rule established by the 1.3.0 eq/hash work (#223/#224), the reason the v1.4 milestone existed, and the reason FACADE-CONTRACT's "warning-free on 1.4" anchor works at all. 3.0's shim removals follow the same bridge. The CONVERSE rule, declined-with-reasoning twice in the migration design (2026-07-11): a bridge warning must WAIT when the replacement does not yet exist — 1.4 warned on neither shared-CONSTANTS mutation nor the subclass hooks, because users had no actionable response until 2.0, and #262's contract docs shipped in the same release (warn-while-documenting is a mixed message).

- (A) v1 field vocabulary at the facade boundary: CJK semantics squeeze into first/last through HumanName while the core speaks given/family. 3.0 could drop the aliasing entirely.
- (A) Render's v1-inherited string_format defaults, including space-joined output for interpunct transcriptions (#298: str(HumanName("威廉·莎士比亚")) == "威廉 莎士比亚"; users reinstate the dot via custom format). 3.0 render defaults are a clean slate.
- (A) The differential harness baseline is 1.4-on-PyPI: post-shim, the migration promise it verifies dissolves; the successor baseline is presumably last-2.x. Machinery survives; corpus contracts change.
- (A) A .pyi stub for Policy (wide __init__, narrow attributes) — deferred from #334, see the differential-ledger Declined entry.
- (A) nameparser.config removal scope: the 3.0 schedule says
  "nameparser.config in its entirety" while actually enumerating
  only the five shim exports; whether the DATA modules keep the package as their home or move under the core is open, and Lexicon's public field docs cross-reference nameparser.config.particles et al. (see the maintainer note in nameparser/config/__init__.py).
- (A) `force_mixed_case_capitalization` as a second route into R5's override (2026-08-29, recorded while extracting R5). The core takes `force` per call and nothing else; the attribute reaches the same behavior only because the facade resolves it when no per-call value is given, which is v1 parity and nothing more. Measured: with it True, a bare `.capitalize()` on `HumanName('Shirley Maclaine')` leaves it `'Shirley MacLaine'` (mutator: read the result back with `str()`). Without the shim there is one route, and R5's statement could say so plainly instead of being written to cover both. Recorded as a removal CANDIDATE — nothing is decided here, and the bridge discipline above applies whenever it is.
- (B) Pickle-guard layout breaks landing in minors (2.1's __setstate__ breaks): the guarded-raise design is right regardless; only the in-a-minor friction is shim-era.
- (B) Positional-read-when-unlicensed as the safe direction (script_orders fallbacks, #298 dot-suppression granularity): coincides with 1.4 parity but stands alone — family-first is the marked case needing affirmative evidence.
- (B) The deprecation-bridge shape (#293/#354) is the template for every remaining 2.x→3.0 shim (the bridge DISCIPLINE — warn in a released version first — predates it, from #223/#224; this entry is the module-__getattr__ mechanics): PEP 562 module __getattr__ PLUS __all__ (star imports never reach __getattr__ — measured: `from ...prefixes import *` bound nothing and leaked the helper); warn per read-location rather than per process (a write-back let a vendored dependency's first read consume the only warning); and the TYPE_CHECKING split, because a module __getattr__ silently disables mypy's attr-defined checking for the whole module (measured on a py.typed package).
- (A) Parking lot from the 2026-07-06 config-model discussion, 3.0-shaped by design: scoped config override via contextvars (`with nameparser.config.use(c):`, the decimal.localcontext pattern — fixes test pollution and config wars while keeping the shared default), and a requests-Session-style Parser(constants) API (rejected then as a full rewrite; the shared CONSTANTS survives 2.x because it is the only config channel that reaches parses inside code users don't own — #262's contract framing).
- (B) The 1.3.0-era legacy-pickle property-key skip (suffixes_prefixes_titles in old blobs) rides in the shim's __setstate__ and dies with it.
- (B) The FAMILY_COMMA doctrine (rule W3): inherited from v1's lastname-comma but correct on its own terms — an explicit comma is stronger evidence than script.
- (A) 2026-08-17 — PARTICLE VOCABULARY AS LOCALE PACKS. Raised while reversing the order-independence decision above: every argument in the leading-particle thread is a language judgement wearing a position heuristic. `de` and `do` lead surnames in Vietnamese, `von` is German, `dos` Portuguese, `das` both Portuguese and a borne Bengali surname — and the per-word comments in nameparser/config/particles.py cite exactly these traditions as their justification. Locale packs already exist (ru, tr_az, zh, ja) and already carry vocabulary and rotations, so particles are the obvious next tenant: a caller who knows the tradition could opt into a tuned set instead of the parser splitting the difference globally. Not 2.x work — it needs the packs to carry vocabulary OVERRIDES rather than additions, and it would give C-i's positional qualifier a per-locale answer rather than one global one.
- (A) 2026-08-18 — LOCALE CONTROL OF P3's SINGLE-LETTER CARVE-OUT, and the sharper requirement it puts on the entry above: a pack would have to carry per-word POLICY, not just membership. Which single letters a tradition wants joined differs by language — Spanish writes "y" and would want "e" read as an initial, Portuguese wants the reverse, Dutch would want every single letter read as an initial. One global rule serves all three today: a single ALPHABETIC character in a name of fewer than four pieces stays a name word, and a bare Latin capital never joins (#383). It is v1's Google Code issue 11 fix ("john e smith") generalized to a vocabulary v1 did not have — the set now holds six single-letter conjunctions (y, e, и, і, й, و), and the carve-out reaches all of them because it counts characters, while the capital veto reaches only the Latin ones because it tests a Latin shape. That asymmetry is the visible seam and it is why #383 is open. A pack that could only ADD or REPLACE vocabulary could not express any of it; the join threshold and the initial veto would have to be per-word, per-pack values. Measured while documenting P3: "&" is single-character but not a letter, so it joins at any length ("Juan & Garcia" is one part while "Juan y Garcia" is three).
