diff --git a/docs/design/decisions.md b/docs/design/decisions.md index c1ef4384..e065204f 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -557,22 +557,32 @@ Excluded (MAIDEN_MARKERS, per nameparser/config/maiden_markers.py): - 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. +- 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. (#436/#437 took group's call away again on 2026-09-06, leaving assign the one caller; the predicate itself stays in the piece layer.) 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. + 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 — that half still stands; BETWEEN pieces it continued an entry, and only a piece that rendered into the same run could do that. The entry was sticky across a piece that was not in it, so an interleaved title did not split the run it sat in (`Smith, MD Dr. PhD` renders 'MD PhD'), and a delimiter core closed it, which was the one thing that separated two entries. That between-piece half is GONE since 2026-09-06: #436/#437 replaced it with a rule over the commas the writer typed, the bullet below. Both readings it was built for are unchanged — the stickiness holds by construction now rather than by code. 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. + 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. CLOSED 2026-09-06 by #436/#437, the bullet below — every sentence above stands as what that tree measured, and all three of its open clauses are now spent: the question WAS filed, as #436 and #437; the boundary moved from segment shape to the comma the writer typed, which reaches the no-comma path because it never asks about segments at all; and round-tripping is stable on these shapes, pinned by `test_a_suffix_run_survives_a_render_and_a_reparse` in tests/v2/test_render.py. 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. + 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. TWO readers since 2026-09-06: #436/#437 took the render join out of group, and `segment_suffix_reading`'s remaining readers are assign's gate and assign's router, one call site between them. The predicate is not moved — one call site is no reason to relocate what two sites will contest again — and what is worth carrying away is unchanged, since the reason the three had to agree is the reason the two still do. 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. +- 2026-09-06 #436/#437 — the entry boundary is the comma the writer typed, not the shape of the segments. Two consecutive post-nominals are one entry iff they sit in the same comma bucket and nothing between them parts the run; `comma_bucket` is the function `segment` builds segments with, so "same part" here is an identity with segment's answer rather than a resemblance to it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). What PARTS a run is a name word — given, middle or family — or a dropped delimiter core (#206). What does NOT part it is a token whose role renders into some other field altogether: the pass names all three of them beside itself as `_RENDERS_ELSEWHERE` — title, nickname and maiden — and such a token is not standing in the run at all, which is what keeps `Smith, MD Dr. PhD` one entry (#429's stickiness, now by construction) and `Smith, MD "Doc" PhD` and `Smith, MD (nee Jones) PhD` with it. Nor does a dropped token that is not a core part it: that is the bare maiden marker of `Smith, MD nee Jones PhD`, dropped with no role at all, so the role test cannot see it. Which is why the dropped arm reads the core SET rather than treating every dropped index as a boundary — a core is the one dropped token the writer typed AS a separator — and the set is `delimiter_cores` in the vocabulary layer, the same derivation off `Policy.extra_suffix_delimiters` that group's drop site reads, imported rather than repeated so the drop site and this pass cannot disagree about what a core is. That is this file's own ONE-PREDICATE-PER-QUESTION again, one level down from the readers #430 counted. FOUND, and it is the reason this is a fix rather than a change: R1 stated the rule in words on 2026-08-23 and the NO-COMMA path had violated it since the sentence was written — #429 added the sentence and recorded the violation without fixing it — its bullet above opens "Left alone, measured and pre-existing" and says of the no-comma path that it "is a separate question". `John Smith MD PhD` rendered suffix 'MD, PhD' at 1.4.0, 2.0.0, 2.1.0 and 2.2.0 alike, a comma nobody typed, and it is now R1's own example line. This bullet is R1's recorded history as well as C1's — `rules.md#R1`'s `history:` field points at `decisions.md#C1` and at nothing else, so the separator rule's provenance has always lived under the suffix-comma decision, and a reader arriving from R1 arrives here. + WHAT GROUP LOST, and it is the whole between-piece half: `tail` by index, #429's `segment_suffix_reading` verdict ORed into it, the per-piece gate, and the stickiness across an interleaved title. Three shape-derived answers to one question was the defect, and the review of #429 had already found that shape wrong in both directions at once. The stickiness needs no code now: `Smith, MD Dr. PhD` has no comma between MD and PhD and the title between them renders into another field, so they join, while `Smith Jr., Mr. Jr.` has the writer's own comma and they do not — both still pinned. What stays in group is core DROPPING keyed on `tail` through `seg_cores` (v1 `expand_suffix_delimiter` parity, #206, a TAIL rule) and the within-piece tag that heals the `Ph. D.` merge, which is role-blind and right for every role. `segment_suffix_reading` is left where it is with ONE call site, in assign; the #430 entry above records three readers, and the render join was the third. + AMENDED FROM THE SPEC TWICE, both times by measurement, and this is the half worth reading twice. The approved predicate parted an entry only at a DROPPED delimiter core. Measured 2026-09-06, the two corpus names the spec names as that class — `Smith, MD - PhD - FACS` and `John Doe, MD - PhD - FACS` — drop no core at all under the default policy the corpora compare under, the dash being no delimiter there: the dashes survive as ordinary given/middle words between PhD and FACS, and a rule reading `dropped` alone joins them where 'PhD, FACS' is pinned. `cases.py`'s `suffix_delimiter_detection` had the same shape pinned already. So the predicate asks what STANDS between the two rather than only what was dropped, and both halves are pinned, one row each, because a dropped-core test alone passes on the rule that broke the corpus. Reading the dropped arm against the core set also retires the spec's open question of how the pass tells a core from a maiden marker: no `WorkToken` tag and no new `ParseState` field, the set being the one group already derives. + THEN AMENDED AGAIN, in review of the FIRST DRAFT of this bundle's code commit — caught before that commit was rewritten, so the commit as it shipped already names all three transparent roles — and the second find is worth as much as the decision. That first draft's predicate made TITLE the only transparent role and treated every dropped index as a boundary, and it was wrong in both directions at once. Measured 2026-09-06 against the parent tree — parse the strings on a pristine checkout of the parent, then against a copy of the pass with `_RENDERS_ELSEWHERE` cut to the title alone and the dropped arm reading any drop as a boundary — three comma-path shapes that render 'MD PhD' at the parent went to 'MD, PhD': `Smith, MD "Doc" PhD`, `Smith, MD (nee Jones) PhD` and `Smith, MD nee Jones PhD`, a nickname, a parenthesised maiden clause and a bare maiden marker, one regression apiece. And the no-comma twin of the first, `John Smith MD "Doc" PhD`, rendered 'MD, PhD' at the parent and stayed there under that predicate — the very bug being fixed, left unfixed on a nickname-carrying shape. The differential witnessed none of the four, so the gate was green over all of it and the code review is what caught it; the general form is that a transparency written from the roles a corpus happens to hold reaches only the roles that corpus happens to hold. Four stage tests pin the four shapes now, and the pass renders 'MD PhD' for all four. + MEASURED 2026-09-06, before and after in the same worktree, the gate exiting 0 at every baseline with `unexplained: 0` and `radar unclassified: 0` throughout: intentional diffs 352 → 360 at 1.4.0, 247 → 254 at 2.0.0, 155 → 166 at 2.1.0 and 14 → 28 at 2.2.0, Latin-only 247 → 255, 151 → 158, 151 → 158 and 14 → 24. The corpus itself grows by exactly one name, the R1 example `John Smith MD PhD` entering `corpus_rules.jsonl` — 1113 → 1114 at 1.4.0 and 1120 → 1121 at the other three — and that is the +1 above the mover count at every baseline. Recipe: `tools/differential/compare.py --baseline B` at each of the four, whole output kept rather than the summary line; the movers themselves from the spec's Recompute block extended to read `state.dropped`, the token roles and the core set, which the bare comma-bucket version cannot do — it reports 15, the two delimiter-core names being the two it joins wrongly. Exactly ONE name changes RULE, at two baselines and no more: `abdul Smith Jr V` at 2.0.0 and 2.1.0, off `fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix` and onto the compound rule, its widened diff no longer being a subset of `fix(#401)`'s `fields`. Checked by spying `compare.classify` and diffing the WHOLE (name → rule) map per baseline, not the gate's printed sections, which list each rule's first ten names and would hide a swap inside a larger one. Six new rules across the four ledgers at fourteen placements: the Latin alternation in all four, the bound-given compound at 2.0.0 and 2.1.0, and four one-name CJK rules at 2.1.0 and 2.2.0. The Latin alternation sits FIRST in each ledger on purpose — moved LAST in a scratch copy of the ledger with `compare._allowlist_for` redirected at the copy, the run reports three undeclared order-decided contests at 1.4.0, three at 2.0.0, three at 2.1.0 and none at 2.2.0. + VIEWS, over the 1117 corpus names — the 1116 that were there plus the R1 example: exactly 13 of the 1116 pre-existing names move, and the new example `John Smith MD PhD` makes 14; each of them moves in `suffix_list` and in the v2 dictionary and token-tag list and nowhere else. No other `*_list` view and no v2 role's token list moves on any name in the corpora. Recipe: for every corpus name dump the facade's eleven `*_list` properties plus `initials_list()` (a method, not a property) and, from `parse(name)`, `as_dict()`, `tokens_for(role)` for each role and every token's tags, before and after, and diff key by key. The tags are dumped beside the views because the change IS a tag change, and a view that renders the same over a token whose tags moved is what a view-only snapshot cannot see. Every mover goes from a comma-joined render to a space-joined one; none goes the other way, and none has a comma in the original between the two words. + RE-PINNED, nine `cases.py` rows and not the two the spec expected: `title_plus_one_word_two_suffixes`, `family_segment_multiple_suffixes`, `suffix_run`, `family_comma_segment_zero_is_not_the_run`, `family_comma_numeral_behind_a_suffix_is_not_an_initial` and the four CJK honorific rows. Eight assertions outside the case table moved with them, four of those in the v1-parity suite (`tests/test_capitalization.py`, `tests/test_suffixes.py`), and they are a deliberate v1 deviation rather than a parity break: v1 inserted a comma into a run the writer had spaced, its own comments calling that "not ideal but at least its in the right bucket", and the release-log bullet names the deviation. Four rules.md example lines outside R1 — P2's `John Smith Mc V`, P5's `abdul Smith Jr Ma`, C1's `Smith, John PhD I.` and W3's `田中さん 様.` — carried the comma too; all four names are differential corpus entries, the last of them in `corpus_cjk_tolerated.jsonl`, so the movement was observed by the gate and not only by the doc test that executes them. `family_comma_segment_zero_is_not_the_run` keeps its NAME and its point — segment 0 is the family segment even when wholly credential-shaped, which is what the mutation matrix found and what the row still guards — and only its separator moves. The comma-joined values the nine carried were the shape machinery's artifact and never a reading anyone chose. + ROUND-TRIPPING is stable on the shapes #429 fixed, which is what that entry recorded as the one load-bearing consequence of leaving this open: `str()` of a fixed parse is a no-comma string, and the no-comma path put the comma back. `test_a_suffix_run_survives_a_render_and_a_reparse` in tests/v2/test_render.py asserts `parse(str(parse(s))).suffix == parse(s).suffix` over the three writings. NOT stable in general, and the general claim is the one to resist — measured 2026-09-06 by evaluating that same equality over every corpus name, at the parent tree and here: 35 of 1116 names failed at the parent and 23 of 1117 fail here, thirteen fixed and ONE newly failing. The one is `Smith, John PhD I.`, and it is this same artifact seen from the other side: it round-tripped only because the render emitted 'John Smith PhD, I.' with a comma the writer never typed, which the no-comma path read back as a run. The render writes 'John Smith PhD I.' now, and that string reads middle 'Smith PhD', family 'I.' at the parent and here alike — a bare trailing initial behind a name is name material, #430's own boundary — so the two spellings genuinely disagree and the comma had been hiding it. The 23 that remain are the standing class: `str()` is a rendering, not a canonical form, and no rule in this file promises it re-parses. + NOT FIXED, and named because this change makes it reachable on more inputs: `revise(n, suffix=n.suffix)` is not the identity on a space-joined run. `Parser.revise` classifies each value by a full sub-parse of that value alone — its own docstring records the limit, the value being "classified ON ITS OWN" — and a bare 'MD PhD' carries no comma for the entry rule to route by, so the two words come back as two entries and the field re-renders 'MD, PhD'. Pre-existing, and the same shape as the `str()` limit above rather than a new one. Measured 2026-09-06 over every corpus name, comparing `revise(parse(n), suffix=parse(n).suffix).suffix` against `parse(n).suffix` at the parent tree and here: 24 of the parent's 1116 names fail and 38 of the 1117 here do, none of the parent's 24 recovering, and the 14 added are exactly the 14 movers — the 13 pre-existing names plus the R1 example. Not this change's to fix: the entry rule reads the commas in a whole name, and what `revise` hands its sub-parse is a field, so closing it means deciding what a field value's separators mean, which is a `revise` question. + DECLINED, all four with the evidence: marking the boundary at the core-drop site (#437's MARK-DONT-STRIP shape) — `dropped` already holds the fact with its span, and a second recording of it is the duplication that mechanism exists to prevent, one level up; making the `"joined"` tag role-aware — within a piece it is role-blind and correct for every role, `Smith, Ph. D. Smith` giving `first_list == ['Ph. D.']`, and only the between-piece half was ever a suffix concept; a render-time span scan instead of the recorded tag — `_facade.__setstate__` and `ParsedName.replace()` synthesise span-less tokens, so an unpickled name has nothing to scan and the tag IS the entry structure a pickle carries; and fixing the no-comma path inside group's block as a third branch beside `tail` and `reading`, which is the shape #429 took. ### T1 — separators, not joiners @@ -898,7 +908,7 @@ Decisions that landed: - 2026-09-05 #498 — a contest a run MEASURES, whose winner outranks a loser by neither narrow-first nesting nor a `precedes_narrower` declaration, must be pinned in `compare._RECORDED_DIFFS` and `_CROSS_RULE_WINNERS`, and `compare.unowned_contests` fails the gate otherwise. RUN-TIME by necessity, and no static sibling exists to write: a pin is a shape beside a winner and the shape is what a run measured, so without a diff the population is the static reach of every pair sharing a corpus name whose `fields` are equal or merely overlap, which is 60 overlapping pairs and 11 more with equal `fields`, 71 in all — and demanding a pin for each is the roster nobody writes, which is the answer this file already gives about the 657 and 111 figures. With a diff it is 14. The 71 is the equal-or-overlap class the check's predicate names, and it is the 2026-09-02 bullet's own five figures re-measured 2026-09-05 by that bullet's recipe — `_rule_reach` per ledger over the `corpus*.jsonl` union, 1116 distinct names — reading 11 wide-first nested, 40 nested either way, 11 equal and 111 intersecting, so 60 overlap without nesting or equality and 71 are equal or overlapping. Every "60 pairs" in this file and in tools/differential/README.md is that overlap-only figure; the population this check would need statically is the 71. FATAL on both tiers, in the exit expression beside `shape_bad`: a contest row is fatal on either tier because it carries an argument, and an owed row because it carries none yet. Scoping it to contract tier was declined 2026-09-02 for the roster this completes and the reason still holds — the roster already pins radar names, 32 of the 45 rows at 1.4.0. A `_WATCHED_DIFFS` row does NOT justify a contest, which is why the check takes one roster and not the union: that roster records a shape with NO WINNER, and which rule should win is the whole of what a contest asks. What makes "a key in `_RECORDED_DIFFS`" mean "a winner is pinned" is the `set(winners) == set(shapes)` equality in tests/v2/test_ledger_guards.py, held in both directions — a borrowing of the same kind `_declared_over` makes of `validate_rules`, and stated in the docstring rather than assumed. NO VACANCY HALF, unlike `vacant_exemptions` beside the static check, and the asymmetry is deliberate: a declaration whose pair stopped being a contest justifies a hazard that is gone and reads exactly like a live one, where a pin whose contest dissolves goes on saying what the name diffs to and which rule takes it — which is what 13 of the 45 rows at 1.4.0 already do with a single admitter apiece (mechanisms.md#RECORDED-ROSTERS: the test for a recorded row is not "is it exercised" but "would its consumer answer differently if it were wrong"). So nothing inverts under a subset either: under `--corpus` the population is the entries the run loaded, so the check UNDER-reports and never false-alarms, and `full_corpus` is not read. Two limits, real and accepted: one ledger per invocation and only when the gate runs; and a `_RECORDED_DIFFS` row is a DEFAULT-order shape, so a contest measured under a declared order would be reported and no row could absorb it. None exists on any shipped ledger today — every one of the fourteen is order-None — and the message names that limit only when a reported row carries an order, the way `NOT CHECKED` does. - 2026-09-05 #498 — the predicate STOPS at this class, and the wider one is measured before it is declined. A pin is owed where the winner beats a loser whose `fields` are EQUAL to or OVERLAP (neither nesting) the winner's. Narrow-first stays the declaration-free default #382 set, and a declared wide-first pair stays covered by its `why`. The wider predicate — a pin for every undeclared contest, narrow-first ones included — costs 9 more names at 1.4.0, 5 contract and 4 radar, 23 in all, and the case FOR it is real: it would have forced the argument that #501's regex accident lacked, on a contract-tier name whose winner's prose was false of it. Declined as a pin the default already justifies, and the cost is recorded rather than argued away: the gap #501 called (ii) — a regex accident in a narrow-first or non-nested pair having no declaration site — stays OPEN BY CHOICE. What #498 changes about it is the non-nested half alone, and by a pin rather than a declaration site. RECOMPUTE: drop the narrow-first clause from the loop below; 23 names at 1.4.0 over 24 name-pair rows, the fourteen this arc pinned among them (measured 2026-09-05, with the fourteen removed from the pinned set so the run reads the tree as this arc opened it). - 2026-09-05 #498 — the COVERAGE argument, and what the 2026-09-02 decline actually declined. That bullet declined REPLACING the static predicate with a run-time one, on coverage alone: a run sees one ledger — the one for the baseline it was invoked with — and only once somebody invokes the tool, where a static predicate covers a rule from the moment it is written, including a rule nobody has ever run the wheel against. It is unchanged, and this check does not touch it. This ADDS: the static check covers the hazard that has a declaration site, this covers the hazard that has none, and between them every measured contest has an owner. Nothing in this entry rests on what a run COSTS: the "multi-minute run" phrase was withdrawn 2026-09-03, a whole gate run is a fraction of a second, and the reason this check is run-time is that a pin needs a measured shape — there is no static form of the question, which is a different claim from the one speed was ever used to make. -- 2026-09-05 #498 — `'Smith, Ph. D. III'`, `'Smith, Ph. D. Jr.'`, `'Smith, Ph. D. Jr. MD'`, `'Smith, Ph. D. MD'`, `'Smith, Ph.D. Jr.'` and `'Smith, PhD Jr.'` → `fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes`, over `fix(comma-family) lone post-comma piece routes to suffix/title, not first`, at 1.4.0. EQUAL `fields` — both declare `{given, title, suffix}` — so `fields` separates the two not at all and file order is the whole decision (sorted indices 10 and 12). Measured against the 1.4.0 wheel, the wheel splits the post-comma run across `title`, `given` and `suffix` and the tree puts the whole of it in `suffix`, whatever the wheel held in `title` or `given` emptying into it: `'Smith, Ph. D. Jr.'` reads title `'Jr.'` → `''` and suffix `'Ph. D.'` → `'Ph. D. Jr.'`; `'Smith, Ph. D. III'` given `'III'` → `''`, suffix → `'Ph. D. III'`; `'Smith, Ph. D. Jr. MD'` title `'Jr.'` → `''`, given `'MD'` → `''`, suffix → `'Ph. D. Jr. MD'`; `'Smith, Ph. D. MD'` title `'MD'` → `''`, suffix → `'Ph. D. MD'`; `'Smith, Ph.D. Jr.'` given `'Ph.D.'` → `''`, suffix `'Jr.'` → `'Ph.D. Jr.'`; `'Smith, PhD Jr.'` title `'PhD'` → `''`, given `'Jr.'` → `''`, suffix `''` → `'PhD Jr.'`. Family is `'Smith'` on both sides of all six. The prose test lands in the LOSER's own terms: the winner's comment says the credential run "was no longer the LONE post-comma piece", and the loser's prose is about a single post-comma piece (`'Smith, Dr.'`, `'Andrews, M.D.'`), which none of the six is. `'Smith, PhD Jr.'` is a REACH and not a fit, its credential unsplit, and the winning rule's comment names it separately as one "which the regex reaches too" — a documented reach whose alternative describes nothing is not the regex accident #372 refuses. Two halves of that same comment disagree with the measurement and are recorded in the row's comment in tests/v2/test_ledger_guards.py rather than repaired here: it renders the new suffix `'PhD, Jr.'`, with a comma no run produces, and says "title 'PhD' at every baseline" where the 1.4.0 title moves to `''`. What the pin buys, measured: moving the loser ahead hands all six over and moves 7 of the 352 classifications — the six plus `'Smith, Jr., PhD'` as collateral — and `fields` being equal, the reorder is the whole of THAT edit; dropping `suffix` from the winner's `fields` hands over all six on its own. +- 2026-09-05 #498 — `'Smith, Ph. D. III'`, `'Smith, Ph. D. Jr.'`, `'Smith, Ph. D. Jr. MD'`, `'Smith, Ph. D. MD'`, `'Smith, Ph.D. Jr.'` and `'Smith, PhD Jr.'` → `fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes`, over `fix(comma-family) lone post-comma piece routes to suffix/title, not first`, at 1.4.0. EQUAL `fields` — both declare `{given, title, suffix}` — so `fields` separates the two not at all and file order is the whole decision (sorted indices 10 and 12). Measured against the 1.4.0 wheel, the wheel splits the post-comma run across `title`, `given` and `suffix` and the tree puts the whole of it in `suffix`, whatever the wheel held in `title` or `given` emptying into it: `'Smith, Ph. D. Jr.'` reads title `'Jr.'` → `''` and suffix `'Ph. D.'` → `'Ph. D. Jr.'`; `'Smith, Ph. D. III'` given `'III'` → `''`, suffix → `'Ph. D. III'`; `'Smith, Ph. D. Jr. MD'` title `'Jr.'` → `''`, given `'MD'` → `''`, suffix → `'Ph. D. Jr. MD'`; `'Smith, Ph. D. MD'` title `'MD'` → `''`, suffix → `'Ph. D. MD'`; `'Smith, Ph.D. Jr.'` given `'Ph.D.'` → `''`, suffix `'Jr.'` → `'Ph.D. Jr.'`; `'Smith, PhD Jr.'` title `'PhD'` → `''`, given `'Jr.'` → `''`, suffix `''` → `'PhD Jr.'`. Family is `'Smith'` on both sides of all six. The prose test lands in the LOSER's own terms: the winner's comment says the credential run "was no longer the LONE post-comma piece", and the loser's prose is about a single post-comma piece (`'Smith, Dr.'`, `'Andrews, M.D.'`), which none of the six is. `'Smith, PhD Jr.'` is a REACH and not a fit, its credential unsplit, and the winning rule's comment names it separately as one "which the regex reaches too" — a documented reach whose alternative describes nothing is not the regex accident #372 refuses. Two halves of that same comment disagree with the measurement and are recorded in the row's comment in tests/v2/test_ledger_guards.py rather than repaired here: it renders the new suffix `'PhD, Jr.'`, with a comma no run produces, and says "title 'PhD' at every baseline" where the 1.4.0 title moves to `''`. HALF REPAIRED 2026-09-06 by #436/#437, which had to touch that comment anyway: the `'PhD, Jr.'` half is corrected to `'PhD Jr.'` in all three ledgers that carry the comment, the line naming the date and saying it had been stale since #429 made the run one entry. The "title 'PhD' at every baseline" half stands as written and is still the live discrepancy — measured again 2026-09-06, the tree reads `'Smith, PhD Jr.'` as family `'Smith'` and suffix `'PhD Jr.'` with title `''`, so the 1.4.0 title moves, and the row's comment in tests/v2/test_ledger_guards.py remains where that half is on the record. What the pin buys, measured: moving the loser ahead hands all six over and moves 7 of the 352 classifications — the six plus `'Smith, Jr., PhD'` as collateral — and `fields` being equal, the reorder is the whole of THAT edit; dropping `suffix` from the winner's `fields` hands over all six on its own. - 2026-09-05 #498 — `'マイケル・ジャクソン'`, `'威廉・莎士比亚'` and `'高橋・一郎'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip`, at 1.4.0. The pair #498 was filed on: `{family, given, middle}` against `{family, given, nickname}`, intersecting in `{family, given}` with neither containing the other (sorted indices 0 and 30). Measured against the 1.4.0 wheel all three diff exactly `{family, given}` — `'マイケル・ジャクソン'` reads first `'マイケル・ジャクソン'`, last `''` on the wheel and given `'マイケル'`, family `'ジャクソン'` on the tree; `'威廉・莎士比亚'` given `'莎士比亚'`, family `'威廉'`; `'高橋・一郎'` given `'一郎'`, family `'高橋'` — and `nickname` is EMPTY on both sides of all three. The loser's subject is a name doing two things at once, "the corner-bracket/nakaguro handling ... changes `nickname`, while the same name's wholly-CJK remainder takes the 2.1 family-first flip", and the half it is named for did not fire here, so its prose describes NONE of this diff where the winner's describes the whole of it and names `'マイケル・ジャクソン'` among its own examples. All three are contract tier. What the pin buys, measured: moving the loser ahead hands all three over and moves 3 of the 352 classifications, no collateral. A `fields` narrowing is NOT the same edit and is not an alternative to it: dropping `given` or `family` from the winner hands the three over and moves 43 of the 352 — the three to the nickname rule, three to `fix(cjk-honorific-suffix)`, two to `fix(cjk-comma-compound)` and THIRTY-FIVE to no rule at all, every one of the 35 a contract-tier name (31 loaded from `corpus_cjk.jsonl` and 4 from `corpus_rules.jsonl`, read the way `main()` stamps a name's file, contract files first; nine of the 35 sit in more than one contract corpus), which is an `unexplained` gate failure rather than a reattribution. Read that as the reason the reorder is the only single edit whose cost is countable in these three names (measured 2026-09-05 by the recipe below, the same one with a role dropped instead of a rule moved). The loser's own positional note says it "sits after the tighter single-change rules on purpose" — a ledger comment's adjudication, which nothing in the tree could check until this row. - 2026-09-05 #498 — `'Dr 김민준, Jr.'` and `'田中さん, Dr.'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-comma-compound) comma routing compounds with the CJK order flip`, at 1.4.0. `{family, given, middle}` against `{family, given, title, suffix}`, intersecting in `{family, given}` without nesting (sorted indices 0 and 33). Measured against the wheel, `'Dr 김민준, Jr.'` reads first `'김민준'`, last `''` and the tree given `'민준'`, family `'김'`, with title `'Dr'` and suffix `'Jr.'` byte-identical on both sides; `'田中さん, Dr.'` reads first `'田中さん'`, last `''` and the tree given `''`, family `'田中さん'`, title `'Dr.'` unmoved. Both diff `{family, given}` — the hangul segmentation and the family-first flip, the two mechanisms the winner's prose names outright — and the comma routing the loser is named for moved nothing. The loser's own disposition table does NOT concede this shape by name: its one row naming `'田中さん, Dr.'` sends the name to `fix(#296)` under `{middle, suffix}`, a different shape from the `{family, given}` measured here, and the row that does concede to the winner names no name — it is `{given, middle, family} all 23 -> fix(#271/#272/#298), the order flip with no comma routing …` — and carries `middle`, which neither name moves. The measurement is what settles the pair, not the table. What the pin buys, measured: moving the loser ahead hands both over and moves 9 of the 352 — the two, plus seven collateral names off `fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name`; dropping `given` or `family` from the winner also hands both over — to `fix(cjk-comma-compound)`, this pair's loser — but at the price the nakaguro bullet above measures, since it is the same rule and the same two drops: 43 moved and 35 of them UNEXPLAINED. So here too the reorder is the edit whose cost stays inside this ledger's explained set. - 2026-09-05 #498 — `'田中さん, Dr.'` → `fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots`, over `fix(cjk-glued-honorific-peel) glued honorific peels into suffix`, at 1.4.0: the SECOND unowned pair on one name, and the reason fifteen rows sit on fourteen names. `{family, given, middle}` against `{family, given, suffix}`, intersecting in `{family, given}` without nesting (sorted indices 0 and 34). Measured, `suffix` is `''` on both sides and the tree's family is the whole token `'田中さん'`: the glued さん is NOT peeled, where the peel this loser is named for would have taken family to `'田中'` and put さん into `suffix` — which is what does happen to `'田中さん, 様.'` in the next bullet, so the contrast between a mechanism that fired and one that did not sits inside this arc's own measurements. Its stated scope excludes the string anyway, the rule being written for a name with "no space before the honorific and no comma anywhere", and this name has a comma. What the pin buys, measured: moving the loser ahead hands the name over and moves 15 of the 352, much the widest of the seven pairs, the peel rule then also taking names off `fix(cjk-comma-compound)` and `fix(cjk-comma-honorific-peel)`. A `fields` narrowing does NOT buy this pair: dropping `given` or `family` from the winner sends the name to `fix(cjk-comma-compound)` and not to this loser, so a reorder is the only single edit that hands it over. @@ -956,7 +966,7 @@ Declined: 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. +- 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 grouping applies within a piece and — since #436/#437, 2026-09-06 — post_rules applies between two post-nominals the writer did not comma-separate. 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 diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 701c0c3a..240a52ec 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -55,7 +55,7 @@ Problem shape. "Which stage does X?" — asked before attributing behavior in pr ## ONE-PREDICATE-PER-QUESTION — one predicate answers it, and every other site calls that -Problem shape. Two stages need the same answer about the same input, and the one that does not own the decision is about to test for it. Contract statement. Where two sites ask the same question, exactly one predicate answers it and every other site calls that one — never a condition written to match it. The predicate belongs to the QUESTION, not to whichever stage decides: it may sit in a leaf both stages import, and for the leading-title test it must, since the deciding stage is assign and group cannot import assign. How it works. A hand-written mirror agrees with its original only until one of them moves, and the drift is invisible in both directions: each site keeps passing its own tests while they disagree about an input neither covers. Five instances, every one found as a defect before it was found as a pattern — #319 lifted the wholly-suffix predicate into the vocabulary layer "so the comma decision and the honorific peel's segment test cannot drift apart"; #401/#421 lifted the trailing-numeral fork out of assign so the bound-given reserve stopped carrying a copy, its hand-written mirror having been falsified in review more than once — the lesson recorded there being that what must be mirrored is assign's WALK, not merely its condition; #425 replaced that reserve's hand re-derivation of the trailing peel with one function over the view the join would leave; #424 moved assign's leading-title test down because group's own `title()` does not see H2's unlisted abbreviations, so `Xyz. van Johnson` chained where `Dr. van Johnson` did not; #429 moved the no-name-segment test down because group asked by segment INDEX where assign asks by CONTENT. The destination follows the LAYER, not the topic: a predicate over token text goes to `_vocab`, one over pieces and tags to `_pieces`. Both are leaves the stages sit on. The piece layer got its own module only in #439 — until then those predicates collected in `_group`, not because grouping owned them but because `_assign` imports `_group` and cannot be imported back, so group was the one place both stages could reach; five had accumulated across four PRs before the module existed. Stage order is this mechanism's limit, and it forecloses the alternative: where the reader comes AFTER the decider, record the answer on the state instead — `ParseState.order` is that shape, "Recorded rather than recomputed downstream, because the two can differ" — which is unavailable whenever the EARLIER stage is the one asking. (The concrete assign→group import that forced the `_group` collection is gone since #439; what remains is the ordering it was a symptom of, and tests/v2/test_layering.py is where the leaf's contract is now written down.) The cost is a second evaluation of the same predicate, measured for #429 at 1.2–2.2% of a family-comma parse and 0% of every other; recording that number was the right answer there over plumbing a state field the two sites would not otherwise share. Lives in. nameparser/_pipeline/_vocab.py over text (is_wholly_suffix; is_trailing_numeral_suffix — the #401/#421 instance, whose only caller since #439 is the shared peel rather than a stage; and maiden_marker_run, the #434 instance and the clearest two-stage case, called by classify over token texts and by extract over a clause's whitespace words, with group reading the tags classify recorded because it runs later) and nameparser/_pipeline/_pieces.py over pieces: is_suffix_piece, is_leading_title, leading_titles, peel_walk, peel_trailing and segment_suffix_reading are called by both stages — the last of those is #430's instance, where THREE readers share one answer, the render join being the third — while is_title_piece and trailing_start are called by group alone — `trailing_start` being the one to know, since it answers where the trailing run begins and is what P2's chain and M2's walk stop at. tests/v2/test_layering.py holds each module's contract, and a piece predicate growing a dependency on a STAGE shows up there as a widened entry. Reach for it when. You are about to write a condition that mirrors, matches or "does what X does" — or you find a comment saying one does. Grep for the other site's predicate and call it instead. +Problem shape. Two stages need the same answer about the same input, and the one that does not own the decision is about to test for it. Contract statement. Where two sites ask the same question, exactly one predicate answers it and every other site calls that one — never a condition written to match it. The predicate belongs to the QUESTION, not to whichever stage decides: it may sit in a leaf both stages import, and for the leading-title test it must, since the deciding stage is assign and group cannot import assign. How it works. A hand-written mirror agrees with its original only until one of them moves, and the drift is invisible in both directions: each site keeps passing its own tests while they disagree about an input neither covers. Five instances, every one found as a defect before it was found as a pattern — #319 lifted the wholly-suffix predicate into the vocabulary layer "so the comma decision and the honorific peel's segment test cannot drift apart"; #401/#421 lifted the trailing-numeral fork out of assign so the bound-given reserve stopped carrying a copy, its hand-written mirror having been falsified in review more than once — the lesson recorded there being that what must be mirrored is assign's WALK, not merely its condition; #425 replaced that reserve's hand re-derivation of the trailing peel with one function over the view the join would leave; #424 moved assign's leading-title test down because group's own `title()` does not see H2's unlisted abbreviations, so `Xyz. van Johnson` chained where `Dr. van Johnson` did not; #429 moved the no-name-segment test down because group asked by segment INDEX where assign asks by CONTENT. The destination follows the LAYER, not the topic: a predicate over token text goes to `_vocab`, one over pieces and tags to `_pieces`. Both are leaves the stages sit on. The piece layer got its own module only in #439 — until then those predicates collected in `_group`, not because grouping owned them but because `_assign` imports `_group` and cannot be imported back, so group was the one place both stages could reach; five had accumulated across four PRs before the module existed. Stage order is this mechanism's limit, and it forecloses the alternative: where the reader comes AFTER the decider, record the answer on the state instead — `ParseState.order` is that shape, "Recorded rather than recomputed downstream, because the two can differ" — which is unavailable whenever the EARLIER stage is the one asking. (The concrete assign→group import that forced the `_group` collection is gone since #439; what remains is the ordering it was a symptom of, and tests/v2/test_layering.py is where the leaf's contract is now written down.) The cost is a second evaluation of the same predicate, measured for #429 at 1.2–2.2% of a family-comma parse and 0% of every other; recording that number was the right answer there over plumbing a state field the two sites would not otherwise share. Lives in. nameparser/_pipeline/_vocab.py over text (is_wholly_suffix; is_trailing_numeral_suffix — the #401/#421 instance, whose only caller since #439 is the shared peel rather than a stage; and maiden_marker_run, the #434 instance and the clearest two-stage case, called by classify over token texts and by extract over a clause's whitespace words, with group reading the tags classify recorded because it runs later; and delimiter_cores, the #436/#437 instance, read by group where a tail segment DROPS a configured delimiter core and by post_rules where the suffix view's entry boundary asks whether a dropped token was one, with a third reader inside this same module, is_wholly_suffix, where a configured core counts as suffix-shaped) and nameparser/_pipeline/_pieces.py over pieces: is_suffix_piece, leading_titles, peel_walk and peel_trailing are called by both stages, while is_leading_title, is_title_piece and trailing_start are called by group alone (measured 2026-09-06 by call site: `is_leading_title` has no caller in `_assign.py`, which reads `leading_titles` instead — a first draft of this clause listed it among the shared ones) — `trailing_start` being the one to know, since it answers where the trailing run begins and is what P2's chain and M2's walk stop at — and segment_suffix_reading by assign alone since #436/#437, that last one being #430's instance, where THREE readers shared one answer until the render join, group's third, was replaced by a rule over the commas the writer typed (decisions.md#C1, 2026-09-06); it stays where it is, one call site being no reason to move a predicate that two sites will contest again. tests/v2/test_layering.py holds each module's contract, and a piece predicate growing a dependency on a STAGE shows up there as a widened entry. Reach for it when. You are about to write a condition that mirrors, matches or "does what X does" — or you find a comment saying one does. Grep for the other site's predicate and call it instead. ## RENDER-HONORS-THE-PARSE — the parse decides it, the views honor it diff --git a/docs/design/rules.md b/docs/design/rules.md index 79f24711..73ccd979 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -212,7 +212,7 @@ P2. Rationale: a particle is written as part of the surname it trailing run the chain stops before it as before any suffix word, and the peel takes it; where it continues a prefix run, the run takes it as a particle, as P6 reads it after a comma. - "John Smith Mc V" → suffix="Mc, V" + "John Smith Mc V" → suffix="Mc V" "John van Mc" → family="van Mc" Accepted: a caller wanting the combined double-surname reading (#132's ask) has it as the surnames view rather than the @@ -339,7 +339,7 @@ P5. Rationale: some given-name words are incomplete alone — "abdul" "abdul Smith V" → suffix="V" "abdul Smith Jr V" → family="Smith" "abdul Smith Jr Ma" → family="Smith" - "abdul Smith Jr Ma" → suffix="Jr, Ma" + "abdul Smith Jr Ma" → suffix="Jr Ma" "abdul Smith Ma" → suffix="Ma" "abdul Smith Berg Ma" → family="Berg" · boundary "abdul Sir Smith Berg" → given="abdul Sir" @@ -876,7 +876,7 @@ C1. Rationale: a credential run after the comma means the name is in "Smith, PSM I." → suffix="PSM I." "Smith, PSM I." strict-comma-suffixes → suffix="I." "Smith, John V." → middle="V." - "Smith, John PhD I." → suffix="PhD, I." + "Smith, John PhD I." → suffix="PhD I." "Smith, John V" → suffix="V" · boundary "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." "Smith, MD PhD" → suffix="MD PhD" @@ -1122,7 +1122,7 @@ W3. Rationale: a family name declared by a comma is the writer's "남궁민수, 지훈" → family="남궁민수" · boundary "田中さん, Dr." → family="田中さん" "田中さん, PhD" → suffix="さん, PhD" - "田中さん 様." → suffix="さん, 様." + "田中さん 様." → suffix="さん 様." tolerated: native CJK writing has neither a family-comma convention nor a period standing after an honorific, so the four comma lines above and the period line under them illustrate current behavior — changeable without notice — rather than promise it; the line carrying neither, beside them, is W1's claim, which is normative. All five stay watched at every released baseline on the differential's radar tier (tools/differential/corpus_cjk_tolerated.jsonl, projected from the `tolerated` rows of tests/v2/cases.py) instead of its contract tier, and those rows pin them at HEAD. history: decisions.md#W3 · interacts: W1, W2, C1 · implemented: nameparser/_pipeline/_script_segment.py @@ -1225,13 +1225,17 @@ R1. Rationale: a field is a way of reading the parse, not a stored is the one place this is visible, because it is the only field that can hold parts the writer comma-separated: a run of post-nominals written with spaces renders with spaces, and one - written with commas keeps them. + written with commas keeps them. The separator is the comma the + writer typed, read off the words' positions once their roles are + known; a delimiter the configuration names is a separator too, + and so is any name word standing between two post-nominals. "Dr. Juan Q. Xavier de la Vega III" → family="de la Vega" "Hassan, Mohamad Ahmad Ali" middle_as_family → family="Ahmad Ali Hassan" "Hassan, Mohamad Ahmad Ali" → family="Hassan" · boundary "Smith, MD PhD" → suffix="MD PhD" + "John Smith MD PhD" → suffix="MD PhD" "John Smith, MD, Bart" → suffix="MD, Bart" - history: decisions.md#C1 · interacts: O3, P6, R3 · implemented: nameparser/_types.py + history: decisions.md#C1 · interacts: O3, P6, R3 · implemented: nameparser/_pipeline/_post_rules.py, nameparser/_types.py R2. Rationale: callers need the surname with and without its particles — sorting wants "Vega", display wants "de la Vega". diff --git a/docs/modules.rst b/docs/modules.rst index 7766651a..a42935ce 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -51,9 +51,14 @@ Results actual family particles), ``conjunction`` (a joining word, "and"/"y"), ``initial`` (an initial-shaped word in a script that HAS initials — "J." or "А.", - never "씨."), and ``joined`` (a continuation of the - previous token within one merged piece, so the suffix view renders - "Ph. D." as one credential). Every other tag is namespaced + never "씨."), and ``joined`` (a continuation of the token before it + — within one merged piece the tag is role-blind and every view + joins the pair with a space, so it renders "Ph. D." as one + credential and ``Smith, Ph. D. Smith`` gives ``first_list`` + ``['Ph. D.']``, and since #436 it also spans the pieces of one + SUFFIX entry, the run of post-nominals the writer wrote without a + comma — "MD PhD" — which is the half the suffix view alone reads + and joins with a space instead of ", "). Every other tag is namespaced (``vocab:...``) and unstable — never match against those. .. autoclass:: nameparser.Span diff --git a/docs/release_log.rst b/docs/release_log.rst index 8cc77c8d..a4b2885f 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -10,6 +10,8 @@ Release Log - **Record a 2.0.0 change to HumanName.initials() that no release note had classified:** since 2.0.0 the facade initials each WORD of a name part, where 1.4.0 initialed a joined run as one group -- ``HumanName("Juan Velasquez y Garcia").initials()`` is ``J. V. G.`` and was ``J. V G.``; ``Abdul Salam Hassan`` is ``A. S. H.`` and was ``A S. H.``. Nothing changes in 2.3.0; the differential gate now compares ``initials()`` (#484) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions.md`` + - **Fix a space-separated run of post-nominals rendering with a comma the writer never typed.** ``HumanName("John Smith MD PhD").suffix`` is ``MD PhD`` and was ``MD, PhD`` at every release since 1.4.0; ``Kenneth Clarke QC MP`` gives ``QC MP``, and the CJK honorific runs (``田中さん II``, ``김민준 박사 씨``) follow the same rule. This is a deliberate deviation from 1.4.0, which inserted a comma into a run the writer had spaced; the v1-parity suite is re-pinned to match. The comma forms are unchanged -- ``HumanName("Smith, MD, PhD").suffix`` is still ``MD, PhD`` -- because the separator is now the comma the writer typed rather than the shape of the comma segments, and a configured suffix delimiter still parts a run, as does a name word standing between two post-nominals. Round-tripping is fixed for these runs, which the 2.2.0 note below recorded as broken: ``str(HumanName("Smith, MD PhD"))`` is ``Smith MD PhD`` and re-parses to suffix ``MD PhD``, where the comma-written and space-written spellings of one run used to disagree. ``str()`` is still a rendering rather than a canonical form, and one name goes the other way: ``HumanName("Smith, John PhD I.")`` renders ``John Smith PhD I.``, which reads middle ``Smith PhD`` and family ``I.`` as it always has, so that name round-tripped only on the strength of the comma this fix removes. Thirteen names that predate this change move in the differential corpora (fourteen with the example this change adds), all in the same direction, and no other field view moves. One limit is not fixed here: ``Parser.revise(n, suffix=n.suffix)`` is not the identity on a space-joined run, because the bare string has no comma to route by and the words come back as separate entries — pre-existing, ``revise``'s own docstring recording that a value is classified on its own, and reachable on more inputs now. See the ``C1`` entry of ``docs/design/decisions.md`` (closes #436, closes #437) + * 2.2.0 - August 31, 2026 nameparser 2.2 is a rename plus about thirty parsing fixes. diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index 75249617..75895e6e 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -7,11 +7,12 @@ tokens are NEVER joined into strings: the anti-#100 invariant); maiden tail tokens get role=MAIDEN; marker tokens land in dropped. Reads: token tags (from classify), Lexicon.given_name_titles (the -P5 licence, #369), Policy.extra_suffix_delimiters, whose -delimiter-core tokens tail segments drop (v1 suffix_delimiter parity), -and Policy.lenient_comma_suffixes, which segment_suffix_reading takes -to pick the strict or lenient token test (#430/#432) -- no other Policy -field. The v1 "derived titles/prefixes" +P5 licence, #369) and Policy.extra_suffix_delimiters, whose +delimiter-core tokens tail segments drop (v1 suffix_delimiter parity) +-- no other Policy field. Policy.lenient_comma_suffixes left this list +with #436: it reached here only through segment_suffix_reading, whose +render consumer was this stage's one-entry join and now lives in +post_rules. The v1 "derived titles/prefixes" registration becomes piece_tags entries -- per-parse state that dissolves with the state (v1 kept per-parse sets for the same reason). @@ -44,8 +45,7 @@ from nameparser._lexicon import _title_key from nameparser._pipeline._pieces import ( is_leading_title, is_suffix_piece, is_title_piece, - leading_titles, peel_trailing, peel_walk, segment_suffix_reading, - trailing_start, + leading_titles, peel_trailing, peel_walk, trailing_start, ) from nameparser._pipeline._state import ( ParseState, PendingAmbiguity, Structure, WorkToken, @@ -809,45 +809,33 @@ def group(state: ParseState) -> ParseState: # rules.md#C1: "a part that is nothing but suffix words is the # credential run and reads as suffixes, whole" -- WHOLE is this # block's half of the rule, the routing being assign's. - # One comma segment is one suffix entry. `tail` answers that by - # INDEX, which is right wherever assign reads the segment as - # suffixes for the same structural reason -- but not after a - # ONE-WORD family comma, where segment 1 is a name slot that - # assign re-reads by CONTENT: a segment of nothing but - # credentials is the credential run, whole (#296/#325). group - # asking the index while assign asked the content is what made - # 'Smith, MD PhD' render 'MD, PhD' with a comma the writer - # never typed, where the full-name 'John Smith, MD PhD' has - # rendered 'MD PhD' since 1.4.0 (#429). Ask assign's own - # predicate, over the pieces group just built. - # `family_comma` is redundant by invariant and kept for - # locality: segment() emits at most one segment for NO_COMMA, so - # seg_idx == 1 already implies a comma, and under SUFFIX_COMMA - # tail_start is 1, so `tail` short-circuits before this. Nothing - # can pin it -- dropping it is an equivalent mutant over the - # corpora and 65,725 generated inputs -- so it is documented - # rather than tested. - reading = (segment_suffix_reading( - pieces, ptags, tokens, - state.policy.lenient_comma_suffixes) - if family_comma and seg_idx == 1 else None) - one_entry = tail or reading is not None - if one_entry: - # v1 renders each tail COMMA SEGMENT as one suffix entry - # ('Smith, V MD' -> suffix 'V MD'); a delimiter core inside - # a segment separates entries and is dropped, but a segment - # that IS only the core stays whole (v1 expand() splits - # within a part, never erases a lone part). Continuation - # tokens within an entry take the stable "joined" tag so - # the suffix view space-joins them (the fix_phd mechanism). - # Core dropping stays keyed on `tail` via seg_cores: the - # #206 parity is a TAIL rule, and the one-entry join is the - # only half that follows assign's content read. - entry_open = False - # non-Optional for the per-piece loop: inside `one_entry`, - # `reading is None` implies `tail`, so the tuple is only - # indexed where it exists - entry = reading or () + # + # v1 expand_suffix_delimiter parity (#206): a delimiter core + # inside a segment separates suffix entries and is dropped, but + # a segment that IS only the core stays whole (v1 expand() + # splits within a part, never erases a lone part). Keyed on + # `tail` through `seg_cores`, which is empty off a tail + # segment, because the #206 parity is a TAIL rule. + # + # What this block decides is the #206 core DROP and nothing + # else: a delimiter core inside a tail segment leaves the + # pieces, and `dropped` is where that fact is recorded -- for + # the render, and for post_rules' entry pass, which reads it + # back as the one dropped token that separates two entries. + # + # It used to decide the ENTRY too, marking a continuation + # token "joined" between pieces off segment SHAPE -- `tail` by + # index, ORed since #429 with segment_suffix_reading's + # per-piece content verdict, gated per piece and sticky across + # an interleaved title. post_rules derives that from the + # commas the writer typed instead (#436/#437, rules.md#R1), + # which is what the input answers directly. The stickiness + # survives by construction rather than by code: + # 'Smith, MD Dr. PhD' has no comma between MD and PhD and a + # title between them renders elsewhere, so they join, while + # 'Smith Jr., Mr. Jr.' has the writer's own comma and they do + # not. + if seg_cores: kept: list[int] = [] for k in range(len(pieces)): is_core = (len(pieces[k]) == 1 @@ -855,46 +843,8 @@ def group(state: ParseState) -> ParseState: and len(pieces) > 1) if is_core: dropped.extend(pieces[k]) - entry_open = False continue kept.append(k) - # Two different joins, and conflating them is what a - # widened condition gets wrong. WITHIN a piece (pos > 0) - # the tag renders a merged piece as one unit; the branch - # is written role-blind because the merge is (the ph-d - # pair reaches GIVEN as one element), though no - # multi-token TITLE piece witnesses it -- none turned up - # in 38,892 generated family-comma inputs. - # - # BETWEEN pieces the tag continues an ENTRY, and only - # pieces that render into the same run may do that. On a - # tail segment every kept piece does -- that is what - # `tail` means -- but off it assign routes piece by - # piece, so a title piece is not part of the suffix - # entry. Letting one continue the entry tags a token the - # SUFFIX view never joins and the TITLE view does: - # 'Smith, Rev. Dr.' collapsed title_list to ['Rev. - # Dr.'], and after a pre-comma suffix the tag glued - # across the writer's own comma ('Smith Jr., Mr. Jr.' - # rendered suffix 'Jr. Jr.') -- the inverse of the bug - # this block exists to fix. - # - # Which pieces those are is assign's reading, computed - # once per segment above rather than re-derived here: - # is_suffix_piece alone refuses a numeral continuing a - # credential run, and would render 'Smith, PSM I' as - # 'PSM, I' (#430). - in_entry = tail or entry[k] - for pos, i in enumerate(pieces[k]): - if pos > 0 or (in_entry and entry_open): - tokens[i] = dataclasses.replace( - tokens[i], tags=tokens[i].tags | {"joined"}) - # Sticky across a piece that is not in the entry, so an - # interleaved title does not split the run it sits in: - # 'Smith, MD Dr. PhD' renders suffix 'MD PhD', not - # 'MD, PhD'. A delimiter core still closes the entry - # (above) -- that is the one thing that separates two. - entry_open = entry_open or in_entry if len(kept) != len(pieces): pieces = [pieces[k] for k in kept] ptags = [ptags[k] for k in kept] diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index 5d0aac96..001d3abc 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -144,10 +144,13 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], a title. None when the segment holds a name word and so is not a credential run at all. - ONE answer for three readers -- assign's no-name gate, its - router, and group's one-entry join -- because they must agree piece - for piece. #429 shipped the inverse of its own fix by deriving that - agreement twice (mechanisms.md#ONE-PREDICATE-PER-QUESTION). + ONE answer for two readers, both in _assign.py -- the no-name gate + and the router -- because they must agree piece for piece. #429 + shipped the inverse of its own fix by deriving that agreement twice + (mechanisms.md#ONE-PREDICATE-PER-QUESTION). It answered for a third + until #436: group's one-entry join asked it too, and the render's + entry boundary is a rule over the written commas in post_rules now + (rules.md#R1), which asks this nothing. rules.md#S2's initial veto keeps a roman numeral out of a suffix reading, which is right after a NAME word: 'Smith, John V.' is a diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index d973fd94..cf61e7eb 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -3,13 +3,16 @@ Consumes: tokens (roles assigned), plus pieces and structure -- the particle fold reads the opening piece of segment 0, or of segment 1 under a family comma (#359). structure was always read here, for the -rotation gate. -Produces: tokens with roles adjusted by the post rules, plus the +rotation gate. Also comma_offsets and dropped, which R1's entry pass +below reads to find the separators the writer typed (#436/#437). +Produces: tokens with roles adjusted by the post rules, the stable +"joined" tag on a post-nominal continuing the entry before it, and the ambiguity P6's attachment reports for the fork it decides (#405). -Reads: Policy.patronymic_rules, Policy.middle_as_family; -Lexicon.given_name_titles. +Reads: Policy.patronymic_rules, Policy.middle_as_family, +Policy.extra_suffix_delimiters (R1's entry pass, for the delimiter +cores group drops); Lexicon.given_name_titles. -Implements rules H1, M4, P1, O1, O2 and O3 of docs/design/rules.md; +Implements rules H1, M4, P1, O1, O2, O3 and R1 of docs/design/rules.md; each is cited at its code below, and H1/P1/O1/O2's history lives in docs/design/decisions.md. """ @@ -21,8 +24,9 @@ from nameparser._lexicon import _title_key from nameparser._pipeline._assign import _name_positions from nameparser._pipeline._state import ( - ParseState, PendingAmbiguity, Structure, WorkToken, + ParseState, PendingAmbiguity, Structure, WorkToken, comma_bucket, ) +from nameparser._pipeline._vocab import delimiter_cores from nameparser._policy import PatronymicRule from nameparser._types import ( FOLDED_TAG, UNJOINED_TAG, AmbiguityKind, Role, @@ -50,6 +54,14 @@ #: and `initial` is the shape claim. Neither is a predicate M4 owns. _NEVER_FLIPPED = frozenset({"vocab:bound-given", "initial"}) +#: The roles that are transparent to a run of post-nominals (R1's +#: entry pass below). These three roles render into fields other than +#: the name and the suffix, so a run of +#: post-nominals is not parted by them -- 'Smith, MD Dr. PhD', +#: 'Smith, MD "Doc" PhD' and 'Smith, MD nee Jones PhD' are each one +#: entry. GIVEN/MIDDLE/FAMILY are name words and part it. +_RENDERS_ELSEWHERE = frozenset({Role.TITLE, Role.NICKNAME, Role.MAIDEN}) + def _idx(tokens: list[WorkToken], role: Role) -> list[int]: return [i for i, t in enumerate(tokens) if t.role is role] @@ -606,8 +618,8 @@ def post_rules(state: ParseState) -> ParseState: # work — nothing joins them to a name — so they read as ordinary # name words" # - # Last in the stage, because every rule above can still move a - # token between parts: + # Last of the rules that MOVE a token, because every rule above + # can still move one between parts: # P1's fold, P6's attachment and O3's fold all rewrite roles, and # this reads the roles they settle on. # @@ -625,5 +637,108 @@ def post_rules(state: ParseState) -> ParseState: for i in part: tokens[i] = dataclasses.replace( tokens[i], tags=tokens[i].tags | {UNJOINED_TAG}) + # rules.md#R1: "a run of post-nominals written with spaces renders + # with spaces, and one written with commas keeps them" + # + # The entry boundary, read off the text the writer typed rather + # than off the shape of the segments (#436/#437). Two consecutive + # SUFFIX tokens are one entry iff they sit in the same comma + # bucket AND nothing between them parts the run -- what parts it + # and what does not is spelled out below. The + # comma is the separator the rule names -- comma_bucket is the + # function segment BUILDS segments with and classify asks about + # boundaries, so "same part" here is an identity with segment's + # answer rather than a resemblance to it + # (mechanisms.md#ONE-PREDICATE-PER-QUESTION). + # + # What parts a run: a name word between the two post-nominals + # (GIVEN/MIDDLE/FAMILY), or a dropped delimiter core (#206). What + # does NOT part it: a token whose role is in _RENDERS_ELSEWHERE, + # because it renders into another field entirely and so is not + # standing in the run at all -- 'Smith, MD Dr. PhD', + # 'Smith, MD "Doc" PhD' and 'Smith, MD (nee Jones) PhD' are each + # one entry; and a dropped token that is not a core, which is the + # maiden MARKER of 'Smith, MD nee Jones PhD' (the marker is + # dropped with no role at all, so the role test cannot see it). + # That is why the dropped arm reads the core set instead of + # treating every dropped index as a boundary: a core is the one + # dropped token the writer typed AS a separator, and the set is + # `delimiter_cores` -- group's own derivation off + # Policy.extra_suffix_delimiters, imported rather than repeated + # (mechanisms.md#ONE-PREDICATE-PER-QUESTION). The two sites read + # ONE derivation and differ only in a gate: group drops a core + # only on a `tail` segment, through `seg_cores`, while this arm + # reads `delimiter_cores` whole and asks by TEXT alone. So a + # dropped token whose text the policy names as a delimiter parts + # the run whatever dropped it. The gate is not needed here: a core + # dropped BY GROUP was on a tail segment by construction, and the + # only case the ungated read adds is a maiden marker the policy + # ALSO lists -- under + # `Policy(extra_suffix_delimiters=frozenset({" nee "}))`, + # 'John Doe, MD nee Jones PhD' renders 'MD, PhD' where the default + # policy renders 'MD PhD' (measured 2026-09-06). That is the + # policy's own declaration deciding it: the writer's configuration + # named that text a separator, so the run parts there. + # + # Four shapes were declined, recorded in decisions.md#C1 by the + # bundle that landed this pass: marking the boundary at the + # core-drop site (`dropped` already holds the fact with its + # span, so a second recording of it is the duplication + # MARK-DONT-STRIP exists to prevent); making the "joined" tag + # role-aware (within a piece it is role-blind and right for every + # role -- 'Smith, Ph. D. Smith' gives first_list ['Ph. D.']); + # scanning spans at render time instead of reading the tag; and + # adding a third shape-derived branch inside group's block. + # + # AFTER assign, and THAT is the load-bearing constraint: this keys + # on Role.SUFFIX, and the same span rule run role-blind would join + # the A and B of 'John A B Smith' into one middle_list element + # (test_the_pass_runs_after_roles_are_settled pins it). + # + # Within this stage the pass sits last by convention, not by + # necessity. It reads SUFFIX and _RENDERS_ELSEWHERE, and no rule + # in post_rules writes either: every retag above targets a NAME + # role and nothing else -- `_retag` is called with Role.FAMILY, + # Role.GIVEN, Role.MIDDLE, and with `_name_positions`' return, + # which is those same three; the two `role=Role.FAMILY` replaces + # (P6's attachment, O3's fold) are FAMILY as well. So no rule here + # moves a token into or out of SUFFIX, or into or out of + # {TITLE, NICKNAME, MAIDEN}, and this predicate reads the same + # answer wherever in the stage it stands (measured 2026-09-06 by + # reading the stage's retag targets). + # + # RECORDED as a tag rather than recomputed by the render, because + # the render cannot see a span: _facade.__setstate__ and + # ParsedName.replace() build span-less tokens AFTER the pipeline, + # so an unpickled name has nothing to scan and the tag IS the + # entry structure the pickle carries + # (mechanisms.md#MARK-DONT-STRIP). Every token here has a span -- + # tokenize is the sole producer of a WorkToken, and WorkToken.span + # is not Optional -- so `span.start` is read unguarded. + # + # The `i not in dropped` filter is belt-and-braces: a dropped token + # never carries a SUFFIX role. Cores leave `pieces` before assign + # runs, so assign gives them no role at all; the one class of + # dropped token that arrives already roled is the MAIDEN one that + # tokenize roles from an extracted clause ('Smith, MD (nee Jones) + # PhD' drops index 2, the marker, and it is Role.MAIDEN). The + # filter is here so that `suffixes` and the `parted` scan below + # cannot disagree about what a dropped index is. + dropped = set(state.dropped) + cores = delimiter_cores(state.policy.extra_suffix_delimiters) + suffixes = [i for i, tok in enumerate(tokens) + if tok.role is Role.SUFFIX and i not in dropped] + for previous, current in zip(suffixes, suffixes[1:]): + same_part = (comma_bucket(tokens[previous].span.start, + state.comma_offsets) + == comma_bucket(tokens[current].span.start, + state.comma_offsets)) + parted = any( + tokens[between].text in cores if between in dropped + else tokens[between].role not in _RENDERS_ELSEWHERE + for between in range(previous + 1, current)) + if same_part and not parted: + tokens[current] = dataclasses.replace( + tokens[current], tags=tokens[current].tags | {"joined"}) return dataclasses.replace(state, tokens=tuple(tokens), ambiguities=tuple(ambiguities)) diff --git a/nameparser/_types.py b/nameparser/_types.py index d6e7fd1d..0e0d47dc 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -87,10 +87,15 @@ def __add__(self, other: object) -> NoReturn: # type: ignore[override] #: particles); "conjunction" a joining word ("and", "y"); "initial" an #: initial-shaped word in a script that HAS initials -- "J." or "А.", #: never "씨." (#320); -#: "joined" a continuation of the previous token within one merged -#: piece ("Ph." + "D."), which the suffix view joins with a space -#: instead of ", ". Every other tag is namespaced ("vocab:...") and is -#: unstable debugging provenance -- never match against those. +#: "joined" a continuation of the token before it -- within one +#: merged piece the tag is role-blind and every view joins the pair +#: with a space ("Ph." + "D."; 'Smith, Ph. D. Smith' gives first_list +#: ['Ph. D.']), and since #436 it also spans the pieces of one SUFFIX +#: entry, the run of post-nominals the writer wrote without a comma +#: ("MD PhD"), which is the half the suffix view alone reads and +#: joins with a space instead of ", ". Every other tag is namespaced +#: ("vocab:...") and is unstable debugging provenance -- never match +#: against those. #: This prose is the hand-maintained twin of docs/modules.rst's #: STABLE_TAGS block; nothing pins the two against each other (the #: test only compares the frozenset), so edit both or neither. diff --git a/tests/test_capitalization.py b/tests/test_capitalization.py index 9cad8aaf..4c8f0576 100644 --- a/tests/test_capitalization.py +++ b/tests/test_capitalization.py @@ -95,7 +95,13 @@ def test_capitalize_single_suffix_still_works(self) -> None: def test_capitalize_multiple_suffixes_still_split_correctly(self) -> None: hn = HumanName('JOHN DOE PHD MD') hn.capitalize() - self.assertEqual(hn.suffix_list, ['Ph.D.', 'M.D.']) + # The split this guards is capitalize() giving each word its own + # exception form rather than title-casing the run, and that is + # untouched. The two words are ONE entry since #436 -- the writer + # spaced them, so they render with a space -- and one entry is one + # suffix_list element. A deliberate deviation from 1.4.0, which + # inserted a comma into a run the writer had spaced. + self.assertEqual(hn.suffix_list, ['Ph.D. M.D.']) def test_capitalize_suffix_acronym_with_dots(self) -> None: # Suffixes already written with dots (e.g. "M.D.") should capitalize diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index ad9d3f00..f8f81104 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -21,16 +21,21 @@ def test_two_suffixes(self) -> None: hn = HumanName("Kenneth Clarke QC MP") self.m(hn.first, "Kenneth", hn) self.m(hn.last, "Clarke", hn) - # NOTE: this adds a comma when the original format did not have one. - # not ideal but at least its in the right bucket - self.m(hn.suffix, "QC, MP", hn) + # The writer spaced "QC MP", so #436 renders it as written + # (rules.md#R1). A deliberate deviation from 1.4.0, which added a + # comma the original format did not have. + self.m(hn.suffix, "QC MP", hn) def test_two_suffixes_lastname_comma_format(self) -> None: hn = HumanName("Washington Jr. MD, Franklin") self.m(hn.first, "Franklin", hn) self.m(hn.last, "Washington", hn) - # NOTE: this adds a comma when the original format did not have one. - self.m(hn.suffix, "Jr., MD", hn) + # The writer spaced "Jr. MD", so #436 renders it as written + # (rules.md#R1). This name HAS a writer's comma, but it stands + # between MD and Franklin -- it parts the family from the run, not + # the run from itself. A deliberate deviation from 1.4.0, which + # added a comma the original format did not have. + self.m(hn.suffix, "Jr. MD", hn) def test_roman_numeral_v_suffix_comma_format(self) -> None: # suffix-comma position is unambiguous: 'V' must be a suffix, not a single-letter initial @@ -270,11 +275,14 @@ def test_suffix_delimiter_multiple(self) -> None: def test_suffix_delimiter_no_effect_without_comma(self) -> None: # suffix_delimiter only applies after the comma split; space-separated - # suffixes already work via the no-comma parse path + # suffixes already work via the no-comma parse path. Since #436 the + # run renders with the space the writer typed, so the row shows the + # knob is inert rather than showing a comma nobody asked for -- a + # deliberate deviation from 1.4.0, which added that comma. hn = HumanName("John Doe MD PhD", suffix_delimiter=" - ") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) - self.m(hn.suffix, "MD, PhD", hn) + self.m(hn.suffix, "MD PhD", hn) def test_suffix_delimiter_constants_level(self) -> None: # ran on the shared CONSTANTS in v1; 2.0 deprecates shared mutation, diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 3002f599..9dddd3e7 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -527,14 +527,17 @@ def _check_cjk_shape_purity(self) -> None: "1.4.0 read first 'John', middle 'née Mary', last " "'Jones'"), Case("title_plus_one_word_two_suffixes", "Dr. Smith PhD Jr.", - {"title": "Dr.", "family": "Smith", "suffix": "PhD, Jr."}, + {"title": "Dr.", "family": "Smith", "suffix": "PhD Jr."}, classification="fix(#410)", notes="two suffix pieces, not one. The removed term asked " "whether ANY token carried a suffix role, so a guard " "rebuilt to decline on the SECOND one would look " "correct against every row that carries a single " "credential. 1.4.0 split them, reading first 'Smith', " - "last 'PhD', suffix 'Jr.'"), + "last 'PhD', suffix 'Jr.'. The comma between them was " + "the shape machinery's artifact and never a reading " + "anyone chose: the writer typed no comma, so #436 " + "renders none (rules.md#R1)"), Case("title_plus_one_word_nickname_and_suffix", "Dr. (Bud) Smith Jr.", {"title": "Dr.", "family": "Smith", "suffix": "Jr.", @@ -991,7 +994,14 @@ def _check_cjk_shape_purity(self) -> None: "[Suffix] slot", shape=2), Case("family_segment_multiple_suffixes", "Smith Jr. MD, John", - {"given": "John", "family": "Smith", "suffix": "Jr., MD"}), + {"given": "John", "family": "Smith", "suffix": "Jr. MD"}, + classification="fix(#436/#437)", + notes="the pre-comma segment's post-nominal run, written " + "with spaces and rendered with them since #436. It " + "read 'Jr., MD' at 1.4.0 and through 2.2.0, the " + "one-entry join being asked by segment index and this " + "run standing in segment 0. The corpus twin is " + "'Washington Jr. MD, Franklin'"), Case("family_segment_particle_chain_suffix", "de la Vega III, Juan", {"given": "Juan", "family": "de la Vega", "suffix": "III"}), Case("interior_periods_block_vocab", "Smith, J.R.", @@ -1095,6 +1105,47 @@ def _check_cjk_shape_purity(self) -> None: policy=_SD, notes="a delimiter token in a NAME segment is kept (v1 parity, " "pinned live 2026-07-16)"), + Case("suffix_delimiter_core_between_entries_is_a_boundary", + "John Doe, MD - PhD - FACS", + {"given": "John", "family": "Doe", "suffix": "MD, PhD, FACS"}, + policy=_SD, + classification="fix(#436/#437)", + notes="the DROPPED core half of #436's entry boundary: a " + "suffix-comma tail drops the delimiter cores (#206), " + "and the entry the writer drew ends at each of them. " + "The core is gone before the render sees it, so the " + "boundary is read off the dropped indices rather than " + "off a surviving token. Parity at every baseline"), + Case("suffix_delimiter_core_that_survives_is_a_boundary_too", + "Smith, MD - PhD - FACS", + {"title": "MD", "given": "-", "middle": "-", "family": "Smith", + "suffix": "PhD, FACS"}, + policy=_SD, + classification="fix(#436/#437)", + notes="the other half, and the one a dropped-core test alone " + "would miss. After a FAMILY comma segment 1 is not a " + "tail, so nothing drops the cores and the dashes stand " + "as ordinary name words between the two post-nominals. " + "A run does not span a name word: the separator holds " + "whether the core was dropped or kept, which is why " + "#436's predicate asks what stands between the two " + "rather than only what was dropped. The bare-policy " + "spelling of this string is a corpus name and reads " + "the same, the dash being no delimiter there either"), + Case("family_comma_transparent_then_core", + 'John Doe, MD "Doc" PhD - FACS', + {"given": "John", "family": "Doe", "nickname": "Doc", + "suffix": "MD PhD, FACS"}, + policy=_SD, + classification="fix(#436/#437)", + notes="both arms of #436's predicate in one run: the nickname " + "between MD and PhD renders into another field, so it " + "is transparent and the two are one entry, while the " + "dropped core between PhD and FACS parts them. A row " + "pinning either arm alone passes on a rule that " + "collapses the two into one test -- part at every " + "dropped index and the suffix reads 'MD, PhD, FACS'; " + "part at no dropped index and it reads 'MD PhD FACS'"), Case("comma_extras_become_suffixes", "Smith, John, Extra, Jr.", {"given": "John", "family": "Smith", "suffix": "Extra, Jr."}, ambiguities=("comma-structure",), @@ -1201,7 +1252,12 @@ def _check_cjk_shape_purity(self) -> None: Case("nickname_only", "(Jack)", {"nickname": "Jack"}), Case("suffix_run", "John Jack Kennedy PhD MD", {"given": "John", "middle": "Jack", "family": "Kennedy", - "suffix": "PhD, MD"}), + "suffix": "PhD MD"}, + classification="fix(#436/#437)", + notes="the NO-COMMA path, which is the one rules.md#R1 " + "described and the parser violated from 1.4.0 to " + "2.2.0: no comma stands between PhD and MD, so none " + "is rendered. The corpus twin is 'John Doe MD PhD'"), Case("maiden_marker", "Jane Smith née Jones", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, classification="fix(#274)", @@ -2701,6 +2757,16 @@ def _check_cjk_shape_purity(self) -> None: notes="the entry is sticky across a piece that is not in it: " "an interleaved title must not split the run it sits " "in, or the render inserts the very comma #429 removes"), + Case("family_comma_nickname_between_credentials", + 'Smith, MD "Doc" PhD', + {"family": "Smith", "nickname": "Doc", "suffix": "MD PhD"}, + classification="fix(#436/#437)", + notes="the transparency above is not a TITLE rule: a nickname " + "renders into `nickname`, so it is no more standing in " + "the credential run than a title is, and the writer " + "typed no comma between MD and PhD. 'MD, PhD' at every " + "baseline, and still 'MD, PhD' on #436's first draft, " + "which read only a title as transparent"), Case("family_comma_title_led_run_keeps_the_written_comma", "Smith Jr., Mr. Jr.", {"title": "Mr.", "family": "Smith", "suffix": "Jr., Jr."}, @@ -2726,13 +2792,32 @@ def _check_cjk_shape_purity(self) -> None: "form into line with. Unchanged since 1.4.0, so this row " "fails if a future change fixes one form by breaking the " "other"), + Case("no_comma_suffix_run_renders_unjoined", "John Smith MD PhD", + {"given": "John", "family": "Smith", "suffix": "MD PhD"}, + classification="fix(#436/#437)", + notes="the NO-COMMA path #429 recorded as left alone, and " + "the rules.md#R1 example line. 'MD, PhD' at 1.4.0, " + "2.0.0, 2.1.0 and 2.2.0 alike -- a comma the writer " + "never typed -- because the entry boundary was derived " + "from segment SHAPE and this string has no comma to " + "shape a segment with. It reads the same as the " + "comma-written 'John Smith, MD PhD' above, which is " + "what makes str() of that parse round-trip"), Case("family_comma_segment_zero_is_not_the_run", "MD PhD Jr., John", - {"given": "John", "family": "MD", "suffix": "PhD, Jr."}, + {"given": "John", "family": "MD", "suffix": "PhD Jr."}, + classification="fix(#436/#437)", notes="segment 0 is the family segment even when it is wholly " - "credential-shaped, so the one-entry join is asked of " - "segment 1 alone. Dropping that conjunct left the whole " - "suite green while this shape's suffix silently became " - "'PhD Jr.' (the mutation matrix found it)"), + "credential-shaped, so 'MD' is the FAMILY and not the " + "head of a credential run, and that is what this row " + "still guards. The SEPARATOR is no longer the row's " + "subject. Until #436 it was: the join was asked of " + "segment 1 alone, and dropping that conjunct left the " + "whole suite green while this shape's suffix silently " + "became 'PhD Jr.' (the mutation matrix found it). The " + "conjunct is gone with the block, and 'PhD Jr.' is now " + "the right answer for a different reason -- PhD and " + "Jr. share a comma bucket with nothing between them, " + "so they render as the writer spaced them"), Case("family_comma_run_ending_in_a_numeral", "Smith, PSM I", {"family": "Smith", "suffix": "PSM I"}, classification="fix(#430)", @@ -2803,18 +2888,24 @@ def _check_cjk_shape_purity(self) -> None: "piece -- the Ph./D. pair the #325 split-credential " "merge builds, which carries 'suffix' in its piece tags " "rather than on a single token. A structurally different " - "pin on the same three readers as the PSM rows, so an " - "edit to those cannot quietly unpin the render join"), + "pin on the same readers as the PSM rows, so an edit to " + "those cannot quietly unpin this. #430 counted three of " + "them; since #436 the render join is a rule over the " + "written commas in post_rules, and the two that still " + "share segment_suffix_reading are assign's gate and " + "assign's router"), Case("family_comma_numeral_behind_a_suffix_is_not_an_initial", "Smith, John PhD I.", - {"given": "John", "family": "Smith", "suffix": "PhD, I."}, - notes="THE OTHER BOUNDARY, and parity at every baseline. The " - "period makes a numeral name material only behind a NAME " + {"given": "John", "family": "Smith", "suffix": "PhD I."}, + notes="THE OTHER BOUNDARY. " + "The period makes a numeral name material only behind a NAME " "word; behind a suffix the run owns it, and the first " "draft of #432 read the piece alone and made this middle " - "'I.'. Rendered with the comma because the writer typed " - "no run here -- the segment holds a name, so it is the " - "walk, not the one-entry join"), + "'I.'. Rendered without a comma since #436: the " + "boundary is the comma the writer typed, and this " + "writer typed none between PhD and I. -- the walk " + "still decides the ROLES, which is what this row is " + "about, and no longer decides the separator"), Case("family_comma_strict_keeps_the_initial_veto", "Smith, PSM I.", {"given": "PSM", "family": "Smith", "suffix": "I."}, @@ -3281,14 +3372,19 @@ def _check_cjk_shape_purity(self) -> None: "as a Japanese name. The peel runs before the license " "is consulted, so it now sees 田中 alone"), Case("ja_honorific_glued_before_a_roman_suffix", "田中さん II", - {"family": "田中", "suffix": "さん, II"}, + {"family": "田中", "suffix": "さん II"}, classification="fix(#308) + fix(#271)", notes="an unrelated trailing suffix does not hide the peel " "site: the scan-back steps over II and peels さん off " "the token behind it. Half of the pair that pins " "_is_post_nominal's use of is_suffix_STRICT -- the " "other half is the row below, and swapping in " - "is_suffix_lenient changes that one and not this one", + "is_suffix_lenient changes that one and not this one. " + "The run renders with the space the writer typed " + "since #436; the comma form '田中さん, 様.' " + "(ja_honorific_period_does_not_stop_the_peel) keeps " + "its comma, which is the pair that shows the " + "separator is read from the text", tolerated=True), Case("ja_honorific_glued_before_an_initial", "田中さん V.", {"given": "田中さん", "family": "V."}, @@ -3306,7 +3402,7 @@ def _check_cjk_shape_purity(self) -> None: "initial, not a post-nominal", tolerated=True), Case("ja_honorific_with_a_period_no_comma", "田中さん 様.", - {"family": "田中", "suffix": "さん, 様."}, + {"family": "田中", "suffix": "さん 様."}, classification="fix(#320)", notes="the SPACED form, and the example _vocab.is_initial's " "own docstring cites as what #320 cost. Same fields as " @@ -3336,7 +3432,10 @@ def _check_cjk_shape_purity(self) -> None: "system produces -- the same class as a comma listing " "or a Latin credential. The row still pins #320's " "mechanism at HEAD; what moves is which corpus file " - "carries the text", + "carries the text. The run renders with the space the " + "writer typed since #436; the comma form " + "'田中さん, 様.' below keeps its comma, which is the " + "pair that shows the separator is read from the text", tolerated=True), Case("ja_honorific_period_does_not_stop_the_peel", "田中さん, 様.", {"family": "田中", "suffix": "さん, 様."}, @@ -3645,13 +3744,17 @@ def _check_cjk_shape_purity(self) -> None: "zh_honorific_suffix_spaced's spaced one", shape=6), Case("ko_honorific_glued_given_trailing_suffix", "김민준씨 Jr.", - {"family": "김", "given": "민준", "suffix": "씨, Jr."}, + {"family": "김", "given": "민준", "suffix": "씨 Jr."}, classification="fix(#308) + fix(#271)", notes="the peel site is the last token that is not itself a " "post-nominal, so an unrelated trailing suffix cannot " "hide it -- this now agrees with the comma-written " "'Dr 김민준씨, Jr.', where the suffix comma had " - "already put 씨 within reach", + "already put 씨 within reach. The run renders with the " + "space the writer typed since #436; the comma form " + "'Dr 김민준씨, Jr.' below keeps its comma, which is " + "the pair that shows the separator is read from the " + "text", tolerated=True), Case("ko_honorific_glued_given_suffix_comma", "Dr 김민준씨, Jr.", {"title": "Dr", "family": "김", "given": "민준", @@ -4083,9 +4186,13 @@ def _check_cjk_shape_purity(self) -> None: "last 지훈, and it is the CJK order flip that puts 양 " "in family -- nothing in #308 moves these fields"), Case("ko_honorific_stack", "김민준 박사 씨", - {"family": "김", "given": "민준", "suffix": "박사, 씨"}, + {"family": "김", "given": "민준", "suffix": "박사 씨"}, classification="fix(#307) + fix(#271)", notes="a trailing RUN of honorifics peels whole, like " "'Smith PhD MD' -- the multi-suffix loop the peel " - "shares with Latin suffixes"), + "shares with Latin suffixes. The run renders with the " + "space the writer typed since #436; the comma form " + "'Dr 김민준씨, Jr.' (ko_honorific_glued_given_suffix_" + "comma) keeps its comma, which is the pair that shows " + "the separator is read from the text"), ) diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index f37a97ee..44de7365 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -322,12 +322,14 @@ def test_extra_suffix_delimiter_splits_tail_entries() -> None: assert _piece_texts(out) == [["Smith"], ["John"], ["V", "MD", "PhD"]] slash_idx = next(i for i, t in enumerate(out.tokens) if t.text == "/") assert slash_idx in out.dropped - # "MD" continues the "V MD" entry (joined); "PhD" starts a fresh - # entry across the dropped delimiter, so it does NOT get "joined" - md_tok = next(t for t in out.tokens if t.text == "MD") - phd_tok = next(t for t in out.tokens if t.text == "PhD") - assert "joined" in md_tok.tags - assert "joined" not in phd_tok.tags + # The tagging half moved to post_rules with #436: the core is + # dropped HERE, and the entry it separates is decided there, over + # the spans. test_a_dropped_core_parts_two_entries in + # tests/v2/pipeline/test_post_rules.py is the assertion that used + # to sit on the two lines below this one. + # group writes no between-piece tag at all since #436, and a + # re-add here would glue the run across the dropped core. + assert not any("joined" in t.tags for t in out.tokens) def test_suffix_comma_name_segment_gets_no_additional_count() -> None: diff --git a/tests/v2/pipeline/test_pieces.py b/tests/v2/pipeline/test_pieces.py index fe6376dd..0f9a8db6 100644 --- a/tests/v2/pipeline/test_pieces.py +++ b/tests/v2/pipeline/test_pieces.py @@ -3,7 +3,7 @@ _pieces has had no unit-test module since #439 moved it out of _group; its predicates were reached only end to end through the case table. These pin the two contracts that shape cannot reach: a defensive branch -no parse can produce, and the stability the three readers rest on. +no parse can produce, and the stability its readers rest on. """ from nameparser._lexicon import Lexicon from nameparser._pipeline._assign import assign @@ -60,9 +60,10 @@ def test_the_numeral_veto_refuses_a_multi_token_piece() -> None: def test_the_reading_is_positional_and_total() -> None: - """One verdict per piece, in order -- the invariant all three - readers index by, and the only thing that makes reading[k] mean - pieces[k].""" + """One verdict per piece, in order -- the invariant its readers + index by, and the only thing that makes reading[k] mean pieces[k]. + Three read it until #436 took the render join out of group; + assign's gate and its router are what remain.""" state = _through_group("Smith, MD PSM I") reading = segment_suffix_reading( state.pieces[1], state.piece_tags[1], list(state.tokens), True) diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py index db3609cc..7ca7b164 100644 --- a/tests/v2/pipeline/test_post_rules.py +++ b/tests/v2/pipeline/test_post_rules.py @@ -814,3 +814,143 @@ def test_the_suffix_arm_report_names_the_post_nominal_it_declined() -> None: "family the comma named rather than standing as a " "post-nominal") assert [out.tokens[i].text for i in amb.indices] == ["vd"] + + +def _entry_tags(text: str, policy: Policy | None = None) -> list[tuple[str, bool]]: + """(text, is a continuation) per SUFFIX token, after the stage. + + Read off the tokens rather than off the rendered string: the tag + is what post_rules produces and the ", " join is _types.py's, and + a test that read the string would pass on a stage that tagged + nothing if the render ever stopped honoring the tag. + """ + state = run(ParseState(original=text, lexicon=Lexicon.default(), + policy=policy or Policy())) + return [(tok.text, "joined" in tok.tags) + for i, tok in enumerate(state.tokens) + if tok.role is Role.SUFFIX and i not in set(state.dropped)] + + +def test_a_space_separated_run_is_one_entry() -> None: + """rules.md#R1's no-comma path, which is the whole of #436.""" + assert _entry_tags("John Smith MD PhD") == [("MD", False), ("PhD", True)] + + +def test_the_writers_comma_parts_two_entries() -> None: + """The `same_part` conjunct. Drop it and 'Smith Jr., Mr. Jr.' + renders suffix 'Jr. Jr.' -- the tag gluing a run across the + writer's own comma, which is the inverse of the bug #429 fixed and + the reason the bucket comparison is not optional.""" + assert _entry_tags("Smith, MD, PhD") == [("MD", False), ("PhD", False)] + assert _entry_tags("Smith Jr., Mr. Jr.") == [("Jr.", False), ("Jr.", False)] + + +def test_a_run_of_three_reads_each_gap_on_its_own() -> None: + """The pass walks CONSECUTIVE pairs, so a mixed run decides each + gap against the post-nominal immediately before it. Measure the + bucket against the run's FIRST suffix instead of the predecessor + and the first assertion below reads [F, F, F]: 'FACS' loses its + continuation because 'MD' sits in an earlier bucket than the + 'PhD' it actually follows. The mirror is the same run written the + other way round, where that mutant agrees and only the pairwise + reading distinguishes the two (measured 2026-09-06).""" + assert _entry_tags("John Doe, MD, PhD FACS") == [ + ("MD", False), ("PhD", False), ("FACS", True)] + assert _entry_tags("John Doe, MD PhD, FACS") == [ + ("MD", False), ("PhD", True), ("FACS", False)] + + +def test_an_interleaved_title_does_not_part_the_run() -> None: + """A title renders into another field, so it is transparent to the + entry: 'Smith, MD Dr. PhD' is one run, as it was when group kept + the entry open across a piece that was not in it.""" + assert _entry_tags("Smith, MD Dr. PhD") == [("MD", False), ("PhD", True)] + + +def test_an_interleaved_nickname_does_not_part_the_run() -> None: + """TITLE is not the only transparent role: a nickname renders into + `nickname`, so it is no more in the run than a title is. Narrow + _RENDERS_ELSEWHERE back to {TITLE} and this renders 'MD, PhD'.""" + assert _entry_tags('Smith, MD "Doc" PhD') == [("MD", False), ("PhD", True)] + + +def test_an_interleaved_nickname_does_not_part_a_no_comma_run() -> None: + """The same transparency on the no-comma path #436 is about: with + a name before it there is no family comma, and the run still has + to survive the nickname. Narrow _RENDERS_ELSEWHERE back to + {TITLE} and this renders 'MD, PhD'.""" + assert _entry_tags('John Smith MD "Doc" PhD') == [ + ("MD", False), ("PhD", True)] + + +def test_a_bracketed_maiden_clause_does_not_part_the_run() -> None: + """MAIDEN renders into `maiden`, so the KEPT word of a maiden + clause is transparent too. Narrow _RENDERS_ELSEWHERE back to + {TITLE} and this renders 'MD, PhD'.""" + assert _entry_tags("Smith, MD (nee Jones) PhD") == [ + ("MD", False), ("PhD", True)] + + +def test_a_bare_marker_maiden_clause_does_not_part_the_run() -> None: + """The dropped arm's other half: written without brackets the + marker word itself is DROPPED, with no role for the transparency + test to read. It is not a delimiter core, so it does not part the + run either -- make the dropped arm part on any dropped index and + this renders 'MD, PhD'. The bracketed twin above drops its marker + too, so that mutant fails both; what separates the two tests is + the role test, which sees 'Jones' in both and 'nee' in neither + (measured 2026-09-06).""" + assert _entry_tags("Smith, MD nee Jones PhD") == [ + ("MD", False), ("PhD", True)] + + +def test_a_marker_the_policy_names_as_a_delimiter_parts_the_run() -> None: + """Where the two sites' gates differ, and the one case that + reaches it. group drops a core only on a `tail` segment, through + `seg_cores`; this pass reads `delimiter_cores` whole and asks by + TEXT, so a dropped token the policy names as a delimiter parts the + run whatever dropped it. A maiden marker the policy ALSO lists is + the reachable case, and it parts by the policy's own declaration + (measured 2026-09-06).""" + nee = Policy(extra_suffix_delimiters=frozenset({" nee "})) + assert _entry_tags("John Doe, MD nee Jones PhD", nee) == [ + ("MD", False), ("PhD", False)] + assert _entry_tags("John Doe, MD nee Jones PhD") == [ + ("MD", False), ("PhD", True)] + + +def test_a_dropped_core_parts_two_entries() -> None: + """The `parted` conjunct, dropped-core half (#206). Drop it and + 'John Doe, MD - PhD - FACS' renders 'MD PhD FACS' under this + policy. Moved here from test_group.py's + test_extra_suffix_delimiter_splits_tail_entries with #436: the + core is dropped in group and the entry it separates is decided + here.""" + dash = Policy(extra_suffix_delimiters=frozenset({" - "})) + assert _entry_tags("John Doe, MD - PhD - FACS", dash) == [ + ("MD", False), ("PhD", False), ("FACS", False)] + + +def test_a_surviving_name_word_parts_two_entries() -> None: + """The `parted` conjunct, kept-core half -- the one a dropped-core + test alone cannot reach. After a FAMILY comma segment 1 is no + tail, so nothing drops the dashes and they stand as name words + between the post-nominals. Drop the conjunct and this renders + 'PhD FACS' under EVERY policy, the bare one included, where the + dash is no delimiter at all.""" + for policy in (Policy(), + Policy(extra_suffix_delimiters=frozenset({" - "}))): + assert _entry_tags("Smith, MD - PhD - FACS", policy) == [ + ("PhD", False), ("FACS", False)] + + +def test_the_pass_runs_after_roles_are_settled() -> None: + """Why the pass keys on Role.SUFFIX and runs after assign: the + same span rule asked role-blind would make 'John A B Smith' one + middle_list element. Asserted as the middle staying two tokens, + neither of them a continuation.""" + state = run(ParseState(original="John A B Smith", + lexicon=Lexicon.default(), policy=Policy())) + middles = [tok for tok in state.tokens if tok.role is Role.MIDDLE] + assert [tok.text for tok in middles] == ["A", "B"] + assert not any("joined" in tok.tags for tok in middles) diff --git a/tests/v2/pipeline/test_state.py b/tests/v2/pipeline/test_state.py index ad2cd602..09750e2b 100644 --- a/tests/v2/pipeline/test_state.py +++ b/tests/v2/pipeline/test_state.py @@ -87,13 +87,16 @@ def test_stage_field_ownership() -> None: # spans are fixed at tokenize (the anti-#100 invariant -- tokens # are never re-created), classify touches only tags, and the # role-assigning stages touch only roles (group also tags, for the - # ph-d "joined" marker). + # ph-d "joined" marker, and post_rules for the entry "joined" + # marker beside its folded-middle mark). token_ownership = { "classify": {"tags"}, "group": {"tags", "role"}, "assign": {"role"}, - # post_rules also tags: the middle_as_family fold marks folded - # tokens vocab:folded-middle for the family view's prepend order + # post_rules also tags, twice: the middle_as_family fold marks + # folded tokens vocab:folded-middle for the family view's + # prepend order, and R1's entry pass marks a post-nominal + # "joined" when the writer wrote no comma before it (#436) "post_rules": {"role", "tags"}, } for case in CASES: diff --git a/tests/v2/test_facade.py b/tests/v2/test_facade.py index 784b804b..17d6a21f 100644 --- a/tests/v2/test_facade.py +++ b/tests/v2/test_facade.py @@ -182,10 +182,13 @@ def test_the_joined_tag_never_reaches_a_title( # #429 regression guard role, so a tag placed for the suffix view is read by the title view too. - #429 widened the one-entry join off the tail path, where assign - routes every piece to SUFFIX, onto the family-comma path, where it - does not. Its first draft let any piece open an entry; a title piece - doing so tagged the title behind it, and title_list collapsed. + So the pass that writes it (post_rules' R1 entry pass, #436) walks + the SUFFIX tokens alone and reads every other role as transparent + to the run rather than as part of it. #429's first draft, over the + piece-shaped join this replaced, let any piece open an entry: a + title piece doing so tagged the title behind it, and title_list + collapsed. The shape of that bug survives the rewrite, which is why + this guard does. Asserted on the LIST views because the string views cannot see it -- titles render space-joined either way, which is why the case table @@ -207,12 +210,16 @@ def test_the_joined_tag_never_reaches_a_title( # #429 regression guard k = HumanName("Smith, MD PhD") assert k.suffix_list == ["MD PhD"] # ONE element - # And the pin for the TAG CONDITION itself, which the three above - # miss: they all pin the sticky entry_open update, and the whole - # suite stays green with `in_entry and` dropped from the tag test. - # This needs a suffix piece FIRST -- opening the entry legitimately - # -- and then TWO titles, so the second title is a continuation of - # an entry it does not belong to. + # And the pin for the _RENDERS_ELSEWHERE transparency itself, which + # the three above miss: a role that renders elsewhere is stepped + # OVER by the pass -- it pairs each SUFFIX with the NEXT SUFFIX -- + # and is never stepped onto. Pair a suffix with the tokens that + # follow it instead, and MD reaches both titles here in its own + # comma bucket with nothing parting them: both are tagged, and + # title_list collapses to ['Rev. Dr.']. It needs a suffix word + # FIRST, so a run is legitimately open, and then TWO titles -- + # which is why 'Smith, Rev. Dr.' above (no suffix at all, so the + # pass never runs) cannot reach this mutant. j = HumanName("Smith, MD Rev. Dr.") assert j.suffix == "MD" assert j.title == "Rev. Dr." # identical either way diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index c6e4cfab..d427d4c6 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -863,6 +863,26 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: # is not conjunction vocabulary at all. "fix(#462)": ("john e smith", "maria y lopez", "E.T. Smith", "Maier, Amy I, Jr."), + # #436/#437's rules are literal-anchored, so _CORPUS_CLAIMS' + # reach cannot move under a widening that only reaches names the + # corpora lack. These probes are the wall. Each rule's boundary + # is a WRITING of the same name: the comma forms and the + # delimiter forms render exactly as they did and must arrive + # UNEXPLAINED if they ever stop doing so. + "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": + ("Smith, MD PhD", "John Smith, MD PhD", "Smith, MD, PhD", + "Smith, MD - PhD - FACS", "John Doe, MD - PhD - FACS"), + "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": + ("田中さん V.", "田中さん 様.", "田中さん, Dr."), + "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": + ("田中さん II", "田中さん, 様.", "田中さん 様"), + "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": + ("김민준 박사", "김민준 박사님", "김민준 씨"), + "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": + ("김민준씨", "Dr 김민준씨, Jr.", "김민준씨 (Jimmy)"), + "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": + ("abdul Smith V", "abdul Smith Jr Ma", "abdul Smith Jr", + "Smith Jr V"), } @@ -1471,6 +1491,23 @@ class _LatinCopy(NamedTuple): # _vocab.D are fixed regexes, so there is no wordlist here for the # alternation to drift from. frozenset({" John Smith", " Van Johnson", ", Jr\\."}), + # #436/#437's Latin movers, one corpus name per alternative -- a + # list of names, not a copy of any wordlist, so there is no + # vocabulary for it to drift from. The rule's subject is the + # SEPARATOR between two post-nominals rather than which words are + # post-nominals, so the vocabularies it would otherwise be + # suspected of copying (SUFFIX_WORDS, SUFFIX_ACRONYMS) are not + # what selects these names: 'John Smith Mc V' is here on a + # particle that is also suffix vocabulary and 'Kenneth Clarke QC + # MP' on two acronyms, and a member matching either set would + # reach names that do not move. One set, identical in all four + # ledgers. + frozenset({"JOHN DOE PHD MD", "John Doe MD PhD", + "John Smith MD PhD", "John Smith Mc V", + "Kenneth Clarke QC MP", "Smith, John PhD I\\.", + "The Rt Hon Kenneth Clarke QC MP, HMG", + "Washington Jr\\. MD, Franklin", "abdul Smith Jr Ma", + "abdul Smith Jr V"}), # fix(#445)'s movers, one corpus name per alternative -- a list of # names, not a copy of any wordlist, so there is no vocabulary for # it to drift from. Two sets because the ledgers group the nine @@ -1951,6 +1988,12 @@ def _claim(rule: dict) -> _Claim: #: both is growth into names the rule genuinely describes. _CORPUS_CLAIMS: dict[str, dict[str, _Claim]] = { "expected_since_1.4.0.toml": { + # #436/#437's Latin alternation, first in every ledger. + # Ten corpus names, `suffix` alone: the rule moves the + # SEPARATOR and no role, so a widening that took a role would + # change the row here before it reached the gate. + "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": + _Claim(10, ('suffix',), "30f5314a2662", None), "fix(A2) content-free input names nobody, so every role empties": _Claim(5, ('given',), "1af8d718688b", None), "fix(#335) a marker-led clause leaves the one name word its bare reading": @@ -2153,6 +2196,18 @@ def _claim(rule: dict) -> _Claim: _Claim(18, ('_initials',), "f67d8ebddd56", ('DEFAULT',)), }, "expected_since_2.0.0.toml": { + # #436/#437's Latin alternation, first in every ledger. + # Ten corpus names, `suffix` alone: the rule moves the + # SEPARATOR and no role, so a widening that took a role would + # change the row here before it reached the gate. + "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": + _Claim(10, ('suffix',), "30f5314a2662", None), + # The compound rule, at the two baselines where 'abdul Smith + # Jr V' already diffs {family, given} under fix(#401) and the + # widened diff leaves that rule's `fields`. Three roles here + # because the diff it explains carries all three. + "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": + _Claim(1, ('family', 'given', 'suffix'), "8152bc33f8da", None), "fix(#335) a marker-led clause leaves the one name word its bare reading": _Claim(1, ('maiden', 'nickname'), "c09cc7dba88b", None), "fix(#434) a multi-word maiden marker takes the maiden name": @@ -2310,10 +2365,54 @@ def _claim(rule: dict) -> _Claim: # the 2.0.0 mapping above, the same regex classifying the same # names. "expected_since_2.2.0.toml": { + # #436/#437's Latin alternation, first in every ledger. + # Ten corpus names, `suffix` alone: the rule moves the + # SEPARATOR and no role, so a widening that took a role would + # change the row here before it reached the gate. + "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": + _Claim(10, ('suffix',), "30f5314a2662", None), + # The four one-name CJK rules, literal-anchored, at the two + # baselines where the render is the whole of what moved. A + # reach of 1 is one _CORPUS_CLAIMS cannot police on its own -- + # a widening into names the corpora lack leaves it unmoved -- + # so _MUST_NOT_MATCH carries the boundary probes beside it. + "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": + _Claim(1, ('suffix',), "7afc1a7f6b7b", None), + "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": + _Claim(1, ('suffix',), "4fc52f5a60f2", None), + "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": + _Claim(1, ('suffix',), "6edfa4394c33", None), + "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": + _Claim(1, ('suffix',), "1b67339cf744", None), "fix(#462) the facade keeps an initial-shaped conjunction letter": _Claim(18, ('_initials',), "3dd0e0276be6", ('DEFAULT',)), }, "expected_since_2.1.0.toml": { + # #436/#437's Latin alternation, first in every ledger. + # Ten corpus names, `suffix` alone: the rule moves the + # SEPARATOR and no role, so a widening that took a role would + # change the row here before it reached the gate. + "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": + _Claim(10, ('suffix',), "30f5314a2662", None), + # The four one-name CJK rules, literal-anchored, at the two + # baselines where the render is the whole of what moved. A + # reach of 1 is one _CORPUS_CLAIMS cannot police on its own -- + # a widening into names the corpora lack leaves it unmoved -- + # so _MUST_NOT_MATCH carries the boundary probes beside it. + "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": + _Claim(1, ('suffix',), "7afc1a7f6b7b", None), + "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": + _Claim(1, ('suffix',), "4fc52f5a60f2", None), + "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": + _Claim(1, ('suffix',), "6edfa4394c33", None), + "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": + _Claim(1, ('suffix',), "1b67339cf744", None), + # The compound rule, at the two baselines where 'abdul Smith + # Jr V' already diffs {family, given} under fix(#401) and the + # widened diff leaves that rule's `fields`. Three roles here + # because the diff it explains carries all three. + "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": + _Claim(1, ('family', 'given', 'suffix'), "8152bc33f8da", None), "fix(#371) a suffix never begins a name: the Ph./D. merge declines at the head": _Claim(4, ('family', 'given', 'middle', 'suffix', 'title'), "1425d85a2d86", None), "fix(#335) a marker-led clause leaves the one name word its bare reading": @@ -2749,8 +2848,17 @@ def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: # the new suffix 'PhD, Jr.', with a comma no run produces, # and it says "title 'PhD' at every baseline", where the 1.4.0 # title MOVES, 'PhD' -> '', the whole run going to `suffix`. - # The ledger's prose is a separate concern and is not touched - # here; this row is where the discrepancy is on the record. + # The ledger's prose was a separate concern and was not + # touched by #498; this row is where the discrepancy went on + # the record. HALF of it is repaired since 2026-09-06: #436/ + # #437 had to touch that comment anyway, and corrected the + # 'PhD, Jr.' half to 'PhD Jr.' in all three ledgers carrying + # it, the line naming the date and saying it had been stale + # since #429 made the run one entry. The "title 'PhD' at every + # baseline" half stands as written and is still live -- + # re-measured 2026-09-06, the tree reads 'Smith, PhD Jr.' as + # family 'Smith', suffix 'PhD Jr.', title '' -- so this row + # goes on carrying that half. # 'Smith, Ph. D. Jr.' is contract tier; the other five are # radar and are pinned on the same terms, the argument # being the row's subject and not the tier. Moving the loser diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index d331b473..f5e4d332 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -179,7 +179,8 @@ def test_matches_accepts_explicit_parser() -> None: def test_phd_split_heals_in_the_suffix_view() -> None: # v1 parity via fix_phd: the split credential renders as one suffix assert parse("John Ph. D.").suffix == "Ph. D." - assert parse("John Smith PhD MD").suffix == "PhD, MD" # unchanged + # #436: the writer typed no comma, so none is rendered + assert parse("John Smith PhD MD").suffix == "PhD MD" def test_phd_split_mid_name_is_a_suffix() -> None: @@ -236,7 +237,8 @@ def test_every_ambiguous_acronym_in_a_name_is_reported() -> None: # one coin-flip per acronym: a single-slot record dropped all but # the last, which defeats the point of reporting at all n = parse("John Smith MA JD") - assert n.suffix == "MA, JD" + # the scenery the record is read off; only its separator moved (#436) + assert n.suffix == "MA JD" assert [a.kind for a in n.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME] * 2 assert sorted(t.text for a in n.ambiguities for t in a.tokens) == \ @@ -542,7 +544,10 @@ def test_the_reserve_declines_and_assign_reads_the_unjoined_pieces() -> None: # and behind a merged credential, which assign drops from its walk # so the V is last in it, the family survives n = parse("abdul Smith V Ph. D.") - assert (n.given, n.family, n.suffix) == ("abdul", "Smith", "V, Ph. D.") + # no comma between the numeral and the merged credential, so the two + # are one entry (#436); the V-is-last-in-the-walk point above is a + # claim about the ROLES and is untouched + assert (n.given, n.family, n.suffix) == ("abdul", "Smith", "V Ph. D.") def test_the_reserve_spares_the_family_the_acronym_fork_would_take() -> None: @@ -557,7 +562,8 @@ def test_the_reserve_spares_the_family_the_acronym_fork_would_take() -> None: assert (n.family, n.suffix) == (m.family, m.suffix) assert n.family != "" n = parse("abu Bakar Jr Ed") - assert (n.family, n.suffix) == ("Bakar", "Jr, Ed") + # spaced run, spaced render (#436); the family claim is what moves here + assert (n.family, n.suffix) == ("Bakar", "Jr Ed") # and the join never turns a suffix into a name: unjoined, the # acronym is a credential with words to spare, so 'abdul Smith # Ma' reads as 'John Smith Ma' does (1.4.0 parity restored) diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index ced474e5..17bdee60 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -99,6 +99,25 @@ def test_parsedname_render_and_str_delegate() -> None: assert str(_pn("", [])) == "" +def test_a_suffix_run_survives_a_render_and_a_reparse() -> None: + """str() of a fixed parse re-parses to the same suffix (#436). + + The instability #429 recorded and left open: its fix made + 'Smith, MD PhD' render suffix 'MD PhD', and str() of that parse + is 'Smith MD PhD' -- a NO-COMMA string, which the no-comma path + then read back as 'MD, PhD'. One writing round-tripped, the other + did not, and which was which depended on a comma the render had + already dropped. Three spellings of one run, each its own path + into the entry rule: a lone family plus the run, the full name + plus a comma, and the full name with no comma at all. + """ + for text in ("Smith, MD PhD", "John Smith, MD PhD", + "John Smith MD PhD"): + once = parse(text) + assert once.suffix == "MD PhD", text + assert parse(str(once)).suffix == once.suffix, text + + def _bobdole() -> ParsedName: # "Sir Bob Andrew Dole" # 01234567890123456789 diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 29c1979b..79b972cf 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -78,6 +78,7 @@ "John Smith J.u.n.i.o.r." "John Smith Jr." "John Smith M.A." +"John Smith MD PhD" "John Smith Mc V" "John Smith PhD" "John Smith Q.W.E.R.T." diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index ecda4b19..b64dadb4 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -52,6 +52,38 @@ # to forty times it -- 288 against 8 for the lone post-comma routing # rule, which is the rule a wrong count reached a filed issue on. +# #436/#437: the suffix view's entry boundary became the comma the +# writer typed. rules.md#R1: "a run of post-nominals written with +# spaces renders with spaces, and one written with commas keeps +# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA +# path only since 2.3, which is the whole of what these names move. +# Every one goes from a comma-joined render to a space-joined one; +# none goes the other way, and none has a comma in the original +# between the two words. Roles do not move: this rule declares +# `suffix` alone, so a name whose ROLES move here arrives +# UNEXPLAINED, as it should. +# +# FIRST in the file, and that is narrow-first rather than a +# preference: `fields = ["suffix"]` is a strict subset of several +# rules below, and any of them sitting earlier while both regexes +# reach one corpus name is an undeclared order-decided contest the +# run refuses (#382). Measured 2026-09-06 by moving this rule to +# the end of a scratch copy of this file and re-running the gate, +# which raises 3 such contests here. +# +# An anchored alternation of the names rather than a shape, because +# the shape is "two post-nominals with a space between them" and +# that reaches every listing form in the corpora. The members are a +# list of NAMES and copy no wordlist, which is what +# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; +# _CORPUS_CLAIMS pins the reach at 10 with its digest, and +# _MUST_NOT_MATCH pins the five comma and delimiter forms it must +# never claim. +[[change]] +issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" +name_regex = "^(?:JOHN DOE PHD MD|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|Kenneth Clarke QC MP|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" +fields = ["suffix"] + [[change]] issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots" # '毛 泽东', '김민준': script_orders flips first/last for a name written @@ -482,7 +514,9 @@ issue = "fix(#325) a split credential followed by another suffix after a one-wor # 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', # 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and # 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD, Jr.' now): +# every baseline, suffix 'PhD Jr.' now; this line read +# 'PhD, Jr.' and had been stale since #429 made the run one +# entry, corrected 2026-09-06): # with one word before the comma the name is the listing form, and # the space-split 'Ph. D.' followed by another suffix was no longer # the LONE post-comma piece, so it fell through to the given name -- diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index ffb6980a..de5932a9 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -25,6 +25,38 @@ # GLUED honorific rule precedes the SPACED one, because a name can # carry both shapes at once. +# #436/#437: the suffix view's entry boundary became the comma the +# writer typed. rules.md#R1: "a run of post-nominals written with +# spaces renders with spaces, and one written with commas keeps +# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA +# path only since 2.3, which is the whole of what these names move. +# Every one goes from a comma-joined render to a space-joined one; +# none goes the other way, and none has a comma in the original +# between the two words. Roles do not move: this rule declares +# `suffix` alone, so a name whose ROLES move here arrives +# UNEXPLAINED, as it should. +# +# FIRST in the file, and that is narrow-first rather than a +# preference: `fields = ["suffix"]` is a strict subset of several +# rules below, and any of them sitting earlier while both regexes +# reach one corpus name is an undeclared order-decided contest the +# run refuses (#382). Measured 2026-09-06 by moving this rule to +# the end of a scratch copy of this file and re-running the gate, +# which raises 3 such contests here. +# +# An anchored alternation of the names rather than a shape, because +# the shape is "two post-nominals with a space between them" and +# that reaches every listing form in the corpora. The members are a +# list of NAMES and copy no wordlist, which is what +# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; +# _CORPUS_CLAIMS pins the reach at 10 with its digest, and +# _MUST_NOT_MATCH pins the five comma and delimiter forms it must +# never claim. +[[change]] +issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" +name_regex = "^(?:JOHN DOE PHD MD|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|Kenneth Clarke QC MP|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" +fields = ["suffix"] + [[change]] issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots" # The 2.1 East Asian defaults, in the fields they move name pieces @@ -422,6 +454,23 @@ issue = "fix(#401) the bound-given reserve counts the trailing numeral assign re name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?v$" fields = ["given", "family"] +# #436/#437 compounding with the bound-given reserve, one name. +# 'abdul Smith Jr V' already diffs {family, given} at this baseline +# under fix(#401) above, whose `fields` are those two alone -- so +# once the render moves as well, the diff is {family, given, suffix} +# and no longer a subset of anything fix(#401) declares. Two +# mechanisms, one name: the reserve decides the ROLES and #436 +# decides the SEPARATOR, and the ledger's idiom for that is a rule +# naming both (the cjk-*-compound rules are the precedent). AFTER +# fix(#401) in file order, because its `fields` are a strict subset +# of these and the reverse arrangement is an undeclared contest +# (#382). fix(#401) keeps 'abdul Smith V', which still diffs on the +# two roles alone, so it neither goes dormant nor over-declares. +[[change]] +issue = "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve" +name_regex = "(?i)^abdul\\s+smith\\s+jr\\s+v$" +fields = ["given", "family", "suffix"] + [[change]] issue = "fix(#421) the bound-given join never absorbs a suffix piece" # 'abdul Jr Smith Berg': rules.md#P5 -- "the join never absorbs a @@ -662,7 +711,9 @@ issue = "fix(#325) a split credential followed by another suffix after a one-wor # 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', # 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and # 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD, Jr.' now): +# every baseline, suffix 'PhD Jr.' now; this line read +# 'PhD, Jr.' and had been stale since #429 made the run one +# entry, corrected 2026-09-06): # with one word before the comma the name is the listing form, and # the space-split 'Ph. D.' followed by another suffix was no longer # the LONE post-comma piece, so it fell through to the given name -- @@ -1027,10 +1078,13 @@ issue = "fix(#429) a wholly-credential segment after a one-word family renders a # The roles moved in #325; what #429 adds is the RENDER. #325 shipped # them as 'MD, PhD' -- a comma the writer never typed -- because group # decided "one comma segment is one suffix entry" by segment INDEX -# while assign decided the same segment by CONTENT. group now asks -# assign's own predicate, so the run renders as written and this path -# agrees with the full-name 'John Smith, MD PhD', which has rendered -# 'MD PhD' since 1.4.0. +# while assign decided the same segment by CONTENT. group asked +# assign's own predicate from #429, so the run renders as written +# and this path agrees with the full-name 'John Smith, MD PhD', +# which has rendered 'MD PhD' since 1.4.0. (2026-09-06, #436: the +# render is post_rules' now -- it reads the commas the writer typed +# rather than any predicate about segment content -- and 'MD PhD' +# here is unchanged by that move.) # # The name entered the corpus as the rules.md#C1 example that fix # added, so this entry arrives with it rather than explaining a diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index e84c74d6..03f013aa 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -49,6 +49,72 @@ # the top level here, since a mistyped table name would otherwise read # as an empty ledger rather than as a broken one. +# #436/#437: the suffix view's entry boundary became the comma the +# writer typed. rules.md#R1: "a run of post-nominals written with +# spaces renders with spaces, and one written with commas keeps +# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA +# path only since 2.3, which is the whole of what these names move. +# Every one goes from a comma-joined render to a space-joined one; +# none goes the other way, and none has a comma in the original +# between the two words. Roles do not move: this rule declares +# `suffix` alone, so a name whose ROLES move here arrives +# UNEXPLAINED, as it should. +# +# FIRST in the file, and that is narrow-first rather than a +# preference: `fields = ["suffix"]` is a strict subset of several +# rules below, and any of them sitting earlier while both regexes +# reach one corpus name is an undeclared order-decided contest the +# run refuses (#382). Measured 2026-09-06 by moving this rule to +# the end of a scratch copy of this file and re-running the gate, +# which raises 3 such contests here. +# +# An anchored alternation of the names rather than a shape, because +# the shape is "two post-nominals with a space between them" and +# that reaches every listing form in the corpora. The members are a +# list of NAMES and copy no wordlist, which is what +# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; +# _CORPUS_CLAIMS pins the reach at 10 with its digest, and +# _MUST_NOT_MATCH pins the five comma and delimiter forms it must +# never claim. +[[change]] +issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" +name_regex = "^(?:JOHN DOE PHD MD|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|Kenneth Clarke QC MP|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" +fields = ["suffix"] + +# The four CJK names of #436/#437's class, one rule each. Not one +# alternation: an alternation holding a script-classified member is +# claimed by the honorific pin in tests/v2/test_ledger_guards.py, +# which requires its members to BE the config's CJK honorific +# entries. These are names, so each stands alone, literal-anchored, +# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) +# with _MUST_NOT_MATCH probes for the boundary each one has. +# rules.md#R1: "a run of post-nominals written with spaces renders +# with spaces, and one written with commas keeps them" -- the peel +# puts the honorific in `suffix` (rules.md#W2) and the run it lands +# in is spaced, so the render spaces it. At 1.4.0 and 2.0.0 these +# names already diff on `suffix` under the honorific rules and are +# claimed there; only at 2.1.0 and 2.2.0 is the render the whole of +# what moved. +[[change]] +issue = "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run" +name_regex = "^田中さん II$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the glued honorific and the polite address are one post-nominal run" +name_regex = "^田中さん 様\\.$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run" +name_regex = "^김민준 박사 씨$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run" +name_regex = "^김민준씨 Jr\\.$" +fields = ["suffix"] + [[change]] issue = "fix(#369) a given-name title licenses the bound given-name join with one word to spare" # 'Sheik abdul salam': rules.md#P5 -- "or after a given-name title, @@ -79,6 +145,23 @@ issue = "fix(#401) the bound-given reserve counts the trailing numeral assign re name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?v$" fields = ["given", "family"] +# #436/#437 compounding with the bound-given reserve, one name. +# 'abdul Smith Jr V' already diffs {family, given} at this baseline +# under fix(#401) above, whose `fields` are those two alone -- so +# once the render moves as well, the diff is {family, given, suffix} +# and no longer a subset of anything fix(#401) declares. Two +# mechanisms, one name: the reserve decides the ROLES and #436 +# decides the SEPARATOR, and the ledger's idiom for that is a rule +# naming both (the cjk-*-compound rules are the precedent). AFTER +# fix(#401) in file order, because its `fields` are a strict subset +# of these and the reverse arrangement is an undeclared contest +# (#382). fix(#401) keeps 'abdul Smith V', which still diffs on the +# two roles alone, so it neither goes dormant nor over-declares. +[[change]] +issue = "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve" +name_regex = "(?i)^abdul\\s+smith\\s+jr\\s+v$" +fields = ["given", "family", "suffix"] + [[change]] issue = "fix(#421) the bound-given join never absorbs a suffix piece" # 'abdul Jr Smith Berg': rules.md#P5 -- "the join never absorbs a @@ -329,7 +412,9 @@ issue = "fix(#325) a split credential followed by another suffix after a one-wor # 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', # 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and # 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD, Jr.' now): +# every baseline, suffix 'PhD Jr.' now; this line read +# 'PhD, Jr.' and had been stale since #429 made the run one +# entry, corrected 2026-09-06): # with one word before the comma the name is the listing form, and # the space-split 'Ph. D.' followed by another suffix was no longer # the LONE post-comma piece, so it fell through to the given name -- @@ -964,10 +1049,13 @@ issue = "fix(#429) a wholly-credential segment after a one-word family renders a # The roles moved in #325; what #429 adds is the RENDER. #325 shipped # them as 'MD, PhD' -- a comma the writer never typed -- because group # decided "one comma segment is one suffix entry" by segment INDEX -# while assign decided the same segment by CONTENT. group now asks -# assign's own predicate, so the run renders as written and this path -# agrees with the full-name 'John Smith, MD PhD', which has rendered -# 'MD PhD' since 1.4.0. +# while assign decided the same segment by CONTENT. group asked +# assign's own predicate from #429, so the run renders as written +# and this path agrees with the full-name 'John Smith, MD PhD', +# which has rendered 'MD PhD' since 1.4.0. (2026-09-06, #436: the +# render is post_rules' now -- it reads the commas the writer typed +# rather than any predicate about segment content -- and 'MD PhD' +# here is unchanged by that move.) # # The name entered the corpus as the rules.md#C1 example that fix # added, so this entry arrives with it rather than explaining a diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index 46cbabf0..22aeb839 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -36,6 +36,74 @@ # [[change]] table to a statically defined array, so that line would # block the first entry. +# #436/#437: the suffix view's entry boundary became the comma the +# writer typed. rules.md#R1: "a run of post-nominals written with +# spaces renders with spaces, and one written with commas keeps +# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA +# path only since 2.3, which is the whole of what these names move. +# Every one goes from a comma-joined render to a space-joined one; +# none goes the other way, and none has a comma in the original +# between the two words. Roles do not move: this rule declares +# `suffix` alone, so a name whose ROLES move here arrives +# UNEXPLAINED, as it should. +# +# FIRST in the file, as in the other three ledgers, and here that is +# uniformity rather than force: measured 2026-09-06, moving this +# rule to the end of THIS file raises no undeclared contest and the +# run still exits 0, because no rule above it declares a strict +# superset of `fields = ["suffix"]` and reaches one of these names. +# It sits first anyway so the four ledgers read the same way, and so +# that a later rule widening into `suffix` finds the narrow one +# already ahead of it rather than making this file the one exception +# (#382, mechanisms.md#LEDGER-RULE-SEPARATION). +# +# An anchored alternation of the names rather than a shape, because +# the shape is "two post-nominals with a space between them" and +# that reaches every listing form in the corpora. The members are a +# list of NAMES and copy no wordlist, which is what +# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; +# _CORPUS_CLAIMS pins the reach at 10 with its digest, and +# _MUST_NOT_MATCH pins the five comma and delimiter forms it must +# never claim. +[[change]] +issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" +name_regex = "^(?:JOHN DOE PHD MD|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|Kenneth Clarke QC MP|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" +fields = ["suffix"] + +# The four CJK names of #436/#437's class, one rule each. Not one +# alternation: an alternation holding a script-classified member is +# claimed by the honorific pin in tests/v2/test_ledger_guards.py, +# which requires its members to BE the config's CJK honorific +# entries. These are names, so each stands alone, literal-anchored, +# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) +# with _MUST_NOT_MATCH probes for the boundary each one has. +# rules.md#R1: "a run of post-nominals written with spaces renders +# with spaces, and one written with commas keeps them" -- the peel +# puts the honorific in `suffix` (rules.md#W2) and the run it lands +# in is spaced, so the render spaces it. At 1.4.0 and 2.0.0 these +# names already diff on `suffix` under the honorific rules and are +# claimed there; only at 2.1.0 and 2.2.0 is the render the whole of +# what moved. +[[change]] +issue = "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run" +name_regex = "^田中さん II$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the glued honorific and the polite address are one post-nominal run" +name_regex = "^田中さん 様\\.$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run" +name_regex = "^김민준 박사 씨$" +fields = ["suffix"] + +[[change]] +issue = "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run" +name_regex = "^김민준씨 Jr\\.$" +fields = ["suffix"] + [[change]] issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" # 'Scott E. Werner', 'Amy E Maid', 'Jose E. Maria Santos':