From 961da7a47daafe76adb83e8abcbb4c32b0918af8 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 18 Oct 2026 20:40:20 -0700 Subject: [PATCH 1/4] Split rules/C1 and decisions/C1 into one rule per piece of evidence (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rules/C1 held ten rules. C1 keeps the decision itself (the word count, the lenient/strict knob and the CJK-honorific Accepted clause) and a one-line map of the rest; C3-C11 take the counting of the words before the comma, the ambiguous credential class, paired initials, the all-caps contrast, the postnominal part read before any join, the listing form's part after the comma, the one-character suffix word, the declared delimiter, and what the decision reports. Every example line and Accepted clause moved to the rule it witnesses: the 103 example lines are the same set and corpus_rules.jsonl rebuilds byte-identical. Two lines gained a `· boundary` mark and C7 a no-boundary: reason, so each new rule carries one. No behavior change. decisions/C1 is split by the rule each entry decided. The #429, #436/#437 and #511 entries, which decide R1's entry pass, move to a new decisions/R1, and R1's history points there. The WHAT MOVES, COST, BLAST RADIUS, VIEWS and RE-PINNED paragraphs are cut to one line each (population, counts, baseline, PR), and superseded passages (#562, and the "segment decides" wording in #575 and #603) to their decision and what replaced it. #611 stays under C1, as the issue asks. Every rules/C1, decisions/C1 and bare C1 reference in code, tests, cases.py notes, other rules, decisions, mechanisms.md, AGENTS.md and the release log is retargeted by what it meant. Three new quoted citations (C3 and C7 in _comma.py, C9 in _assign.py) give those rules an implemented: list the citation check holds. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../decisions/C1-suffix-comma-decision.md | 76 +--- ...-delimiter-parts-a-trailing-suffix-part.md | 9 + .../C11-what-the-comma-decision-reports.md | 6 + .../C3-counting-the-words-before-the-comma.md | 13 + ...ambiguous-credential-class-at-the-comma.md | 9 + .../C5-paired-initials-after-the-comma.md | 3 + ...caps-word-needs-the-names-case-contrast.md | 4 + ...ostnominal-part-is-read-before-any-join.md | 9 + .../C8-listing-forms-part-after-the-comma.md | 12 + ...aracter-suffix-word-inside-a-comma-part.md | 11 + .../H6-title-before-a-family-comma.md | 4 +- .../design/decisions/M2-maiden-marker-rule.md | 2 +- docs/design/decisions/P3-connectives.md | 2 +- .../decisions/P6-trailing-orphan-particle.md | 4 +- ...R1-fields-are-views-of-the-parsed-words.md | 22 ++ docs/design/decisions/README.md | 10 + .../decisions/S10-trailing-roman-numeral.md | 2 +- ...S7-company-of-an-unambiguous-credential.md | 2 +- ...-that-join-the-ambiguous-class-by-shape.md | 2 +- docs/design/decisions/cjk-comma-demotion.md | 2 +- docs/design/decisions/comma-suffix-arc.md | 2 +- docs/design/decisions/nfd-shapes.md | 2 +- docs/design/decisions/phd-merge.md | 2 +- docs/design/decisions/v1-xfail-triage.md | 2 +- docs/design/mechanisms.md | 6 +- docs/design/rules/C1-suffix-comma-decision.md | 335 +----------------- ...-delimiter-parts-a-trailing-suffix-part.md | 32 ++ .../C11-what-the-comma-decision-reports.md | 43 +++ .../rules/C2-parts-past-the-second-comma.md | 4 +- .../C3-counting-the-words-before-the-comma.md | 50 +++ ...ambiguous-credential-class-at-the-comma.md | 63 ++++ .../C5-paired-initials-after-the-comma.md | 49 +++ ...caps-word-needs-the-names-case-contrast.md | 27 ++ ...ostnominal-part-is-read-before-any-join.md | 20 ++ .../C8-listing-forms-part-after-the-comma.md | 56 +++ ...aracter-suffix-word-inside-a-comma-part.md | 42 +++ .../rules/H2-leading-abbreviation-title.md | 8 +- .../rules/H6-title-before-a-family-comma.md | 4 +- docs/design/rules/M2-maiden-marker-rule.md | 2 +- docs/design/rules/P3-connectives.md | 8 +- .../rules/P6-trailing-orphan-particle.md | 2 +- ...R1-fields-are-views-of-the-parsed-words.md | 4 +- docs/design/rules/README.md | 9 + .../rules/S10-trailing-roman-numeral.md | 2 +- ...-ends-a-name-and-never-opens-the-string.md | 4 +- ...edentials-written-with-interior-periods.md | 14 +- ...-ambiguous-acronym-needs-words-to-spare.md | 4 +- ...arts-trailing-slot-after-a-family-comma.md | 2 +- .../S6-case-signal-at-the-suffix-slot.md | 6 +- ...S7-company-of-an-unambiguous-credential.md | 2 +- .../rules/S8-credentials-trailing-run.md | 2 +- ...-that-join-the-ambiguous-class-by-shape.md | 14 +- docs/release_log.rst | 4 +- nameparser/_parser.py | 2 +- nameparser/_pipeline/_assign.py | 11 +- nameparser/_pipeline/_comma.py | 31 +- nameparser/_pipeline/_group.py | 2 +- nameparser/_pipeline/_pieces.py | 8 +- nameparser/_pipeline/_post_rules.py | 4 +- nameparser/_pipeline/_vocab.py | 14 +- nameparser/_types.py | 2 +- nameparser/config/suffixes.py | 2 +- tests/test_suffixes.py | 6 +- tests/v2/cases.py | 94 ++--- tests/v2/pipeline/test_classify.py | 2 +- tests/v2/pipeline/test_comma.py | 10 +- tests/v2/pipeline/test_script_segment.py | 2 +- tests/v2/pipeline/test_vocab.py | 4 +- tests/v2/test_benchmark.py | 2 +- tests/v2/test_doc_citations.py | 2 +- tests/v2/test_parser.py | 4 +- tests/v2/test_properties.py | 4 +- tests/v2/test_regex_sync.py | 2 +- 74 files changed, 685 insertions(+), 555 deletions(-) create mode 100644 docs/design/decisions/C10-declared-delimiter-parts-a-trailing-suffix-part.md create mode 100644 docs/design/decisions/C11-what-the-comma-decision-reports.md create mode 100644 docs/design/decisions/C3-counting-the-words-before-the-comma.md create mode 100644 docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md create mode 100644 docs/design/decisions/C5-paired-initials-after-the-comma.md create mode 100644 docs/design/decisions/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md create mode 100644 docs/design/decisions/C7-postnominal-part-is-read-before-any-join.md create mode 100644 docs/design/decisions/C8-listing-forms-part-after-the-comma.md create mode 100644 docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md create mode 100644 docs/design/decisions/R1-fields-are-views-of-the-parsed-words.md create mode 100644 docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md create mode 100644 docs/design/rules/C11-what-the-comma-decision-reports.md create mode 100644 docs/design/rules/C3-counting-the-words-before-the-comma.md create mode 100644 docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md create mode 100644 docs/design/rules/C5-paired-initials-after-the-comma.md create mode 100644 docs/design/rules/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md create mode 100644 docs/design/rules/C7-postnominal-part-is-read-before-any-join.md create mode 100644 docs/design/rules/C8-listing-forms-part-after-the-comma.md create mode 100644 docs/design/rules/C9-one-character-suffix-word-inside-a-comma-part.md diff --git a/AGENTS.md b/AGENTS.md index 78bb0925..2d148e89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,7 @@ The library has two layers: `nameparser/config/` (data) and `nameparser/parser.p **A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`; 568 of 575 against seven, measured 2026-09-25 after #540), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions/suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions/vocabulary-collisions`, with #360's positional qualifier. -**Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found `, PhD` and `, Jr.` (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put an empty head and a nickname-only head (`(Bob), PhD`) in every comma grid. Count the one-word path the way C1 counts, which is neither tokens nor pieces: a particle run and the one word it attaches to are one word (#575), a connective join and a bound given-name pair count as their words (#613, so `Ortega y Gasset, Dr.` is a whole name), and a suffix beside the surname counts as no name word at all. #603's review found the suffix half (`Vega y Lopez Jr., PhD Jones` lost its family where `Vega y Lopez, PhD Jones` kept it); #613 then reversed the connective half, and both now read given 'Vega', middle 'y', family 'Lopez' — so test a particle surname, a connective surname and a surname with a suffix, each before a comma. For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. And read a generated grid's moves by SHAPE, not by corpus membership: #614's first design passed the suite, the five gates and a fingerprint of every corpus name, because its grid moves were sorted into 'corpus name moved' and 'garbage', and the review found the moves that mattered among the 'garbage' -- a bare salutation (`Mr. and Mrs.` lost its connective to the family name) and a name with one trailing word (`Freiherr von Berg X.Y.Z.`). Pull out the head-plus-one-word slice of the grid and a salutation with nothing after it, and read every move there. Two more were the second review's (#614): no grid put TWO words that are both a title and a particle at the head (`Freiherr St van Berg MA`, where an index into the pieces was applied to a merged view of them -- `TITLES ∩ particles_ambiguous` is `{freiherr, st}`, so put both in every head list, each in its bare and its period-marked spelling -- #637's grid had `St` and `Freiherr` alone and missed that `St.` and `Freiherr.` are declined by the bound join and then attached by P6), and the grids compared ambiguity KINDS, so a report telling the caller the wrong field (`Freiherr von Berg Ma`, 'read as a given name' of a word in the family) passed every pin. #701's review added one: put a title/suffix dual (`Ms`, `Sr.`, `Cpl`) SECOND in a title run, behind an ordinary title. A stop that asks only "is this word suffix vocabulary" passes every row that opens with the dual and fails `Rev. Sr. Smith, Mary`; it was found by comparing `T Smith, John` against `T John Smith` over every title word, and a comma-versus-comma-less comparison is the cheap check for any rule that moves a word between the two slots. Compare each report's `detail` against the field its tokens land in -- since #626 `test_a_report_names_the_field_its_word_lands_in` (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its `_FIELD_CLAIMS` lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (`, Ma Dr.`: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too. And #620's: an exclusion list in code being replaced is a set of rules, not noise -- #614's merged copy excluded every word the trailing read weighs from its units, the first #620 prototype dropped the list as a symptom of the copy, and its review found the list had been keeping positional counting's rule that a member in front counts as a word (`Freiherr von Berg MA X.Y.Z.` lost its suffix). Before deleting one, mutate each entry out and read what moves. +**Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found `, PhD` and `, Jr.` (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put an empty head and a nickname-only head (`(Bob), PhD`) in every comma grid. Count the one-word path the way C3 counts, which is neither tokens nor pieces: a particle run and the one word it attaches to are one word (#575), a connective join and a bound given-name pair count as their words (#613, so `Ortega y Gasset, Dr.` is a whole name), and a suffix beside the surname counts as no name word at all. #603's review found the suffix half (`Vega y Lopez Jr., PhD Jones` lost its family where `Vega y Lopez, PhD Jones` kept it); #613 then reversed the connective half, and both now read given 'Vega', middle 'y', family 'Lopez' — so test a particle surname, a connective surname and a surname with a suffix, each before a comma. For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. And read a generated grid's moves by SHAPE, not by corpus membership: #614's first design passed the suite, the five gates and a fingerprint of every corpus name, because its grid moves were sorted into 'corpus name moved' and 'garbage', and the review found the moves that mattered among the 'garbage' -- a bare salutation (`Mr. and Mrs.` lost its connective to the family name) and a name with one trailing word (`Freiherr von Berg X.Y.Z.`). Pull out the head-plus-one-word slice of the grid and a salutation with nothing after it, and read every move there. Two more were the second review's (#614): no grid put TWO words that are both a title and a particle at the head (`Freiherr St van Berg MA`, where an index into the pieces was applied to a merged view of them -- `TITLES ∩ particles_ambiguous` is `{freiherr, st}`, so put both in every head list, each in its bare and its period-marked spelling -- #637's grid had `St` and `Freiherr` alone and missed that `St.` and `Freiherr.` are declined by the bound join and then attached by P6), and the grids compared ambiguity KINDS, so a report telling the caller the wrong field (`Freiherr von Berg Ma`, 'read as a given name' of a word in the family) passed every pin. #701's review added one: put a title/suffix dual (`Ms`, `Sr.`, `Cpl`) SECOND in a title run, behind an ordinary title. A stop that asks only "is this word suffix vocabulary" passes every row that opens with the dual and fails `Rev. Sr. Smith, Mary`; it was found by comparing `T Smith, John` against `T John Smith` over every title word, and a comma-versus-comma-less comparison is the cheap check for any rule that moves a word between the two slots. Compare each report's `detail` against the field its tokens land in -- since #626 `test_a_report_names_the_field_its_word_lands_in` (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its `_FIELD_CLAIMS` lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (`, Ma Dr.`: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too. And #620's: an exclusion list in code being replaced is a set of rules, not noise -- #614's merged copy excluded every word the trailing read weighs from its units, the first #620 prototype dropped the list as a symptom of the copy, and its review found the list had been keeping positional counting's rule that a member in front counts as a word (`Freiherr von Berg MA X.Y.Z.` lost its suffix). Before deleting one, mutate each entry out and read what moves. **Then sweep the spellings: a class reached by vocabulary is also reached by SHAPE.** A test that uses only the listed spelling of a word class walks only the vocabulary path, and the shape path is a separate branch that can be missing while every test passes. Roman numerals are the sharpest case: `ii`, `iii` and `iv` are suffix vocabulary; `i` and `v` are vocabulary too but initial-shaped, so `is_suffix_piece` vetoes them and only the numeral fork reads them; and `vi` through `x` are in no list at all, read by the fork's shape test alone (checked 2026-10-04 against `Lexicon.default()`) -- which stops at `x`: `_vocab._ROMAN` matches I to X, so `John Smith XI` reads `XI` as a name word (re-checked 2026-10-07, #614's /simplify, which found a docstring claiming "any length"). And the fork counts a ONE-letter numeral besides, since #718 (rules/S10): it is the suffix only where the name has a word to spare, so `John V` and `John Smith V` take different branches of one fork, a one-case name being counted like capitals whatever the letter -- since #721 a one-case one-letter numeral carries the `initial` tag its capital does, and since #723 every one-case bare letter does (rules/T4), and a bare letter in front of the numeral is initial-shaped as its capital is, so `john v` reads as `JOHN V` does over `test_a_one_case_name_reads_as_its_capitals`'s grid (comma shapes, orders, a maiden clause, a particle chain, a bare letter in front, a trailing title, a non-ASCII letter in front: 315 of its then 1,872 pairs disagreed at 2bfa9533, before #721, and none after). Its first version claimed "every shape" with no bare letter in front and no trailing title in the grid, and #721's reviews found both broken; a grid's claim is the grid -- put a head-plus-numeral row beside every words-to-spare row. So a change that handles `V` and `III` can still miss `VI` — #610 found exactly that, the maiden take's candidate loop admitting a shape-only numeral in one position where the peel reads it in two (`Jane Doe nee Smith VI Prof.` kept maiden `Smith VI` while `V` and `III` worked). The same split runs through the credentials (`MA` listed, `X.Y.Z.` by the dotted shape, `XYZ` by the caps shape, and `Ph. D.` merged by group into one flagged piece the peel's walk never holds -- #603 found #602's run-start predicate refusing it too, so `Smith, John Ph. D. Jones` kept middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones') and the titles (`Prof.` period-marked and read at the end of a name, `Prof` bare and a name word there). When a change touches a word class, test one spelling from each path. The same holds for a separator's SPACING: a comma (or any punctuation) can stand alone, glue to the word before it or glue to the word after, and each is a different token stream. #653's first fix swept `(née, Jones)` and missed `(née , Jones)`, where the kept comma became a word after the marker and M3 read the clause as maiden ', Jones'; its review found it. Put all three spacings in any grid around a separator. diff --git a/docs/design/decisions/C1-suffix-comma-decision.md b/docs/design/decisions/C1-suffix-comma-decision.md index 6d8bc780..e9f0105a 100644 --- a/docs/design/decisions/C1-suffix-comma-decision.md +++ b/docs/design/decisions/C1-suffix-comma-decision.md @@ -4,80 +4,14 @@ - 2026-07 (plan deviation #3, recorded) — the decision is definitionally vocabulary-dependent: there is no way to recognize a credential run without consulting the suffix word lists, so the structural stage reads vocabulary through one predicate. - 2026-07-12/13 (v2 Policy work, PR #288) — the lenient token test is the default and `lenient_comma_suffixes=False` restores the strict one. (An earlier entry here credited #291/#296 — git author dates place both the field and its wiring in the Policy commits, and the #291/#296 design doc never mentions the knob.) - 2026-08 #319 — the wholly-suffix predicate was lifted into the vocabulary layer so the comma decision and the honorific peel's segment test cannot drift apart. -- 2026-08-23 #296/#325 — the listing form's post-comma part is read for what it is. Three commits of the approved #291/#296 bundle (spec 2026-07-30, plan 2026-08-01; see the comma-suffix arc below), in the plan's order. (1) A comma followed only by titles fixed no family boundary, so the pre-comma name keeps its positional read: `John Smith, Mr.` reads given 'John', family 'Smith', title 'Mr.' where 2.0 had merged the family ('Bob Jones, author' moves with it at every baseline). "Nothing but titles" is title-shaped AND not suffix vocabulary — the plan's helper said only the first, and would have read `Smith, PhD Jr.` as the title run 'PhD Jr.' and `Smith, Mr. Jr.` as 'Mr. Jr.' (both master's suffix 'Jr.'; the gate and a measurement against master caught them). (2) The TITLES/suffix overlap audit and the ordering, one commit because each alone is wrong (the plan's F1): reading the credential run first would make `Smith, Dr.` a suffix while 'dr' was SUFFIX_WORDS residue, and the audit alone leaves `Smith, Jr.` a title by the period-abbreviation inference. The audit table's dispositions ship as approved — 'do', 'jr', 'junior', 'phd', 'se' out of TITLES; 'dr' and 'sra' out of the suffix sets; 'ms' and 'sa' into the ambiguous set — with ONE deviation on evidence the table did not have: 'md' keeps dual membership, because bare 'Md' before a name is the Bengali and South Asian Muslim abbreviation of Muhammad (#343/#345's corpus rows `Md Abdul Karim`, `Md. Abdul Karim`; the drop read the first as given 'Md', middle 'Abdul') while 'MD' after the name is the degree — position decides, as for 'sr'. The ordering is wider than the plan's lone piece: a post-comma part that is nothing but suffix pieces is the credential run, whole, which is what #325 needed (`Smith, Ph. D. Jr.` had put the split credential in the given name once a second suffix made it no longer lone — a 1.4.0 regression; v1 read suffix 'Ph. D.', title 'Jr.'). Vocabulary decides which words qualify and position breaks the tie for the genuine duals: `Smith, Sr.` is Senior where `Sr. Garcia` is Señor, `Smith, Esq.` is the postnominal where H2 still reads `Esq. Smith` as a title (H2 carries that as Accepted). `Smith, Dr. Jr.` keeps the 2.0 deviation's other case — a title, then the last piece as the suffix (v1 read first 'Jr.'; 2.0 through master suffix 'Jr.') — and `Smith, John Jr.` its given name. Consequences the audit carries, all measured and pinned: a leading 'PhD' or 'Jr' is a name word; `Do Quang Minh` reads given 'Do' with the particle-or-given fork, and `Dr. Do Van Johnson, MD` given 'Do', family 'Van Johnson' (the #367 rule for that name is replaced; TITLES ∩ ambiguous is {freiherr, st} now, which keeps the chained emitter reachable); a trailing bare 'Dr' is a name word, joining every other title-only word there (#316 is the open question); `田中さん, PhD` reads suffix 'さん, PhD'; the #100 prefix-join tests use 'lt', a genuine dual, as their token. #291 (multi-word credentials) is the bundle's fourth commit and its own PR. -- 2026-08-23 (review of PR #428, four reviewers) — the repair's condition is "no name word", not "all titles": `John Smith, Mr. Jr.` (a title and a postnominal) had kept the merge the rationale argues against, and reads given 'John', family 'Smith', suffix 'Jr.' now. Its guard counts NAME pieces: the positional read peels a trailing suffix first, so `Smith Jr., Mr.` — two pieces, one name — read positionally lost its family (the code review); it keeps family 'Smith', suffix 'Jr.' at every baseline. The positional read records its ORDER, and post_rules' leading-piece scan keys on "assign records no order after a family comma" rather than on the structure, so P1's family-first fold reaches the pre-comma name as it does without the comma — `de Mesnil Jean, Dr.` under a family-first order had read family 'de' (the test review). The credential run does NOT step over a delimiter core the policy names (`Smith, RN - CRNA` reads given 'RN' under `extra_suffix_delimiters` as without it): the code review asked for it, a draft did it, and tests/test_suffixes.py's "the delimiter must not affect parsing at all" outside the suffix-comma form is v1 parity pinned on purpose — C1 carries the limit as Accepted. A second deviation from the audit table: `ms` and `sa` do NOT join the ambiguous set — the gate is position-blind and the collision is not; gated, `John Smith, MS` lost its suffix-comma route and read title 'MS', and `Smith, Ms.` passed the gate on its one period and read as a credential anyway. Both are genuine duals, and position decides: `Smith, Ms.` reads suffix 'Ms.' (title at every baseline; C1 carries it as Accepted, with `Smith, Ms. Jane` keeping the title) and `John Smith, MS` keeps suffix 'MS'. And the gate itself now means what S2 says: an ambiguous acronym counts when written with ITS periods, one after each letter (`M.A.`, `J.D.`), not on any period — `Smith, Ed.` had become a credential on a name's trailing period, and `Jack Ma.` had read suffix 'Ma.' since 2.0 (S2 carries the boundary; 1.4.0 read family 'Ma.'). Two limits recorded rather than fixed: `田中さん, Dr.` keeps the honorific glued, joining master's `田中さん, Mr.` — the honorific peel runs in script_segment on the suffix-comma and no-comma structures, before group or assign can say the comma fixed nothing (C1 Accepted); and group's chain emitter still reports no PARTICLE_OR_GIVEN on the family-comma path, so `Freiherr von Berg Ma, Mr.` reads as its no-comma twin but without the report — group runs before assign decides which read applies, and plumbing a deferred report through the state is more than the shape is worth today. -- 2026-08-23 #429 — one comma segment is one suffix entry, and the two stages disagreed about which segments those are. group asked by segment INDEX (`tail_start`: 1 after a suffix comma, 2 after a family comma), which is right wherever assign reads the segment as suffixes for the same structural reason — but not after a family comma, where segment 1 is a name slot assign re-reads by CONTENT with `_segment_holds_no_name` and routes piece by piece (#296/#325). So the roles were right and the render was not: `Smith, MD PhD` gave suffix 'MD, PhD' with a comma the writer never typed, and `Smith, CBE MC`, `Smith, BSc MBA` and `Smith, PhD Jr.` the same, while the full-name `John Smith, MD PhD` has rendered 'MD PhD' since 1.4.0. The predicate moved down into group beside the piece predicates it already reads — the same direction `_leading_titles` moved in #424, and the only direction available, since assign imports group. (#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 — 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. 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/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. 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/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/R1`'s `history:` field points at `decisions/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. Answered, and FIXED, by #511 — the next bullet. - 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. -- 2026-09-06 #511 — a suffix value handed to `revise()` derives its entries from its own commas, by the rule a whole name uses. `Parser.revise` sub-parsed each value and forced every harvested token to the named role AFTER the sub-parse had run R1's entry pass, which keys on Role.SUFFIX; a bare 'MD PhD' reads there as a title and a family name, so the pass joined nothing and the field rendered 'MD, PhD'. The fix runs the same pass again: `revise` now sub-parses to a ParseState, forces the role on every non-dropped token, and calls `suffix_entries` — the pass, lifted unedited out of post_rules' tail into a function the two callers share (mechanisms.md#ONE-PREDICATE-PER-QUESTION) — over the forced state, then assembles and harvests as before. So a comma in the value parts two credentials and a space joins them. TWO SPELLINGS of the lifted pass, and the reason is the call budget: a first draft had post_rules call the state-in/state-out wrapper, and the second ParseState build cost three more calls per parse against the band tests/v2/test_benchmark.py holds (py3.11, 2026-09-06, tools/perf/call_count.py: 450 calls/name before the move, 451 with the in-place worker `_mark_suffix_entries` that post_rules now calls, 454 with the draft; the facade band tops at 455.9), so the worker writes in place as every other post rule does and the wrapper exists for the one caller with no token list of its own. NO `_run` HELPER for the same reason: a draft routed `parse()` through a private state builder shared with `revise`, and that cost one frame per parse on the hot path (py3.11, 2026-09-06: 415 calls/name against 414 without it, in a 402-418 band), so the four-line construction is spelled twice and `parse()` is untouched. MEASURED 2026-09-06 over the 1117 distinct names in `tools/differential/corpus*.jsonl`, comparing `revise(p, suffix=p.suffix).suffix` against `p.suffix` under the default parser for every name with a non-empty suffix (368 of the 1117): 38 differed before and 1 after; the differential gate is byte-identical at all four baselines, `revise` not being on the compare path. RECOMPUTE: parse each corpus name, skip an empty suffix, revise the parse with its own suffix, count the names whose suffix moved (the script is in the #511 issue body). THE ONE LEFT is '김민준씨, J.씨', and it is not entry structure: the whole-name parse keeps 'J.씨' one glued suffix token, suffix '씨, J.씨', while the sub-parse of the bare value '씨, J.씨' peels the honorific off the initial, so the revised field renders '씨, J. 씨' — right entries, the spurious comma of before ('씨, J., 씨') gone, one word read differently by the value's own parse than by the whole name's. That is the "classified ON ITS OWN" limit `revise`'s docstring has always recorded, and CJK honorific peeling is a W-rule question this change does not move; pinned in `test_revise_reads_a_glued_honorific_on_its_own`. SUPERSEDES the phd-merge acceptance of 'Ph., D.' on this path (its bullet says how). STALE TAGS, tried and backed out: a draft cleared the sub-parse's own "joined" with the forcing and re-derived it, because the pass only ADDS the tag and `revise(n, family="Jones MD PhD")` carries the sub-parse's between-piece suffix mark on 'PhD' onto a FAMILY token. Measured 2026-09-06 against `330ee55`, the clear also destroyed every WITHIN-piece mark on a non-suffix value — 'D.' of `revise(n, family="John Ph. D. Smith")` lost the merge mark the #436 bullet's DECLINED list calls role-blind and correct for every role — while on a ParsedName only the suffix string view reads "joined" (`_text_for`'s suffix_join gate) — the facade's `_list_for` heals it for every role, but no path puts a revised name into a HumanName, the v1 setters going through `replace()`, so wiring those setters onto `revise` is the change that would show the stale mark, as `last_list == ['Jones', 'MD PhD']` — `initials()` and every field string being identical at both trees for every shape measured. So the tags are kept minus FOLDED_TAG as before; the between-piece mark on a forced non-suffix role is a tag-only oddity that predates this change and stays, and for a suffix value nothing depends on the sub-parse's marks, every pair it joined sharing a bucket with no parting token and the pass setting it again; pinned in `test_revise_keeps_the_sub_parses_within_piece_mark`. Dropped tokens keep their role and tags through the forcing, because the pass filters them by index and assemble omits them. ONE MORE LIMIT, pinned in `test_revise_leaves_a_policy_delimiter_unparted_without_a_tail_segment`, and it is the "classified ON ITS OWN" limit again rather than a rule of revise's: a delimiter the policy names through `extra_suffix_delimiters` is dropped, and so parts entries, only on a segment after a comma that the reading of the words makes a tail, and a value with no comma of its own has none — under `Policy(extra_suffix_delimiters=frozenset({" - "}))` the whole name 'Doe, John, MD PhD - FACS' renders 'MD PhD, FACS' while `revise(n, suffix="MD PhD - FACS")` renders 'MD PhD - FACS', the dash surviving as a token and the forced role making it a suffix word; measured 2026-09-06, a value whose own words read with a tail segment does part ('John Doe, MD - FACS' revises to 'John Doe, MD, FACS') and one after a suffix comma does not ('MD, PhD - FACS' stays), which is how the value's words read as a name deciding it. The round-trip is unaffected, the whole-name view having already rendered that boundary as a comma. DECLINED: a list-valued `revise(p, suffix=["MD PhD", "FACS"])`, which widens the API and leaves the string path where it was; documenting the limit with pins alone, the docstring already recording it and #511's measurement showing it reachable on every space-joined run #436 produced; and a text-only read of the delimiter cores inside `revise`, a second rule for a case no round-trip reaches. Out of scope and left as is: `ParsedName.replace(suffix="MD, PhD")` renders 'MD,, PhD', `replace` whitespace-splitting by contract and the facade setters riding on it for v1 parity. -- 2026-09-27 (Derek), #544 — THE NAME-WORD COUNT READS A RUN. The single-token rule this section records for the ambiguous class generalizes to a post-comma part of two or more words, every one suffix vocabulary or a class member (listed or by shape), at least one a member and none a single-letter roman numeral: behind two or more name words the part is the credential run, and the flip reports once over the whole part (`John Smith, Ed Ma`, `Jane Doe, MS LAc`). A title/suffix dual opening the part counts as suffix vocabulary there, and a run whose every member is listed and leans credential is left to the family-comma path, which already reads it whole. The forks and their measurements are decisions/S7's #544 entry. -- 2026-09-28 (Derek), #544 — A PART READ WHOLLY AS SUFFIXES REPORTS NO NAME READING OF ITS WORDS. group's particle chain runs over every comma segment, and its two emitters — `particle-or-given` when a particle behind a word of both the title and the particle vocabulary chains (since 2.0.0, de264af1) and `suffix-or-name` when the chain takes an ambiguous acronym into the name (#289/#516, 59d8f38a, in no release) — reported inside a TAIL segment, which assign reads wholly as suffixes. Each such report named a reading the parse never made, against rules/A1's "A report names the reading the parse took". A family comma's tail was already silent, since group hands the chain no report list anywhere after a family comma; the suffix comma's tails were not. Measured on the released wheels: `John Smith, Jr., Freiherr von Richthofen` reports `particle-or-given` on 'von', a token in the suffix role, at 2.0.0, 2.1.0, 2.2.0 and 2.3.0; `John Smith, Jr., PhD van Ma` and `John Smith, Jr., PhD Do Ma` report it on 'van' and 'Do' at 2.0.0 and 2.1.0 only. The `suffix-or-name` half reached `John Smith, Jr., PhD Do Ma`, `John Smith, MA, PhD Do Ma` and `John Smith, Jr., PhD van Ma` on master (e10e83b4), and the run rule of the bullet above made it reachable behind ONE comma: `John Smith, PhD Do Ma` carried C1's flip and a second report on 'Ma'. FIXED by scope, not by a new test at the emitter: group passes the chain no report list in a tail segment either, so both emitters go quiet there together, and rules/C2 states the boundary for any part consumed wholly as suffixes. (Mechanism SUPERSEDED by #636, decisions/C1, 2026-10-11: group makes no chain in a tail segment at all, and the part after a suffix comma had reached none since #613, so the report list is withheld only after a family comma.) The maiden channel is a separate parameter and is untouched: a tail segment's reader is NONE, so the maiden walk reports nothing there to begin with. No mechanisms.md entry: this is AMBIGUITY-AT-THE-DECISION-SITE's own contract (a report fires only where the parse chose between live readings) applied to a stage whose reading a later stage overrides for the whole segment. MEASURED 2026-09-28, the tree against the same tree with `None if family_comma else ambiguities` restored in `group()` (the comparator), each parse recorded as its seven fields plus `(kind, [(token text, token role)])` per report, under all three name orders: 0 of the 1441 differential-corpus names move, so the gate has nothing to classify; over a comma grid — the prefixes `John Smith, `, `John Smith, Jr., ` and `Smith, John, ` times every run of one to three words drawn with repetition from {PhD, MA, Ma, Do, van, de, Jr, MEng, Ed, y, i}, each text as written, lowercased and uppercased, deduplicated to 11,049 texts — 360 parses (120 texts, every one of them mixed case) lose one `suffix-or-name` report apiece, every removed report on a token in the suffix role, 0 reports added, 0 field moves. 21 of the 120 texts carry one comma and are the run rule's reach; the other 99 carry two and moved the same way (297 parses) when the same one-line change was applied to master e10e83b4; the `Smith, John, ` prefix moves nothing, being a family comma. The grid holds no word of both the title and the particle vocabulary, so the `particle-or-given` half is witnessed by the case row alone. Pinned by the case rows `a_credential_run_after_the_comma_reports_no_chain_fork` and `a_part_past_the_second_reports_no_particle_fork`; the rows the chain still reports on outside a tail are `the_chain_reports_the_acronym_it_takes` and `titled_particle_chain_survives_a_title_that_is_also_a_particle`. -- 2026-10-01 (Derek), #562 (mechanism and accepted reports superseded by #613, below: the decision now binds the part before any chain runs, so the pair reports nothing) — A PARTICLE CHAIN UNSETTLES A RUN THE CAPITALS SETTLED, AND THE COUNT READS IT. The 2026-09-27 bullet above left a run whose every member is listed and leans credential to the family-comma path, "which already reads it whole". That promise fails wherever two particles stand side by side in the part: group chains them into one particle run (P2), assign reads the part as name text, and P6 attaches the chain to the family — `John Smith, PhD DO DO` read given 'PhD', family 'DO DO John Smith', and `John Smith, DO DO DO` given 'DO', middle 'DO DO'. rules/S2 already said the capitals do not decide a member chained behind another particle ("the run attaches whatever the capitals say (P6)"), so C1's shortcut was resting on a premise S2 denies. Of the two fixes #562 weighed, the one taken narrows the shortcut and leaves S2 as written: a part holding two particles side by side is read by the count, which two name words before the comma flip to the credential run, reported (`suffix-or-name`). The other — letting C1's evidence or S2's credential-in-front company outrank the chain — would have contradicted S2's sentence and P6's `Doe, John van DO` example, so it needed S2 amended rather than a gap filled. The test is ANY two adjacent particles, not a member behind one: `vd` is a particle and an unambiguous suffix word, so `John Smith, PhD vd DO` and `John Smith, MA vd vd` chained and misread the same way, the second with no member behind a particle at all. Segment runs before classify, so it asks classify's own predicate (`_normalize(text) in lexicon.particles`) and only while the run is still settled. The test does not ask whether the family-comma path would actually have misread the part, which it could not without reading ahead to group: where that path did read the part whole — for example a pair opening the part with an unambiguous particle-and-suffix word (`John Smith, vd DO`, `John Smith, VD DO`), a credential that is also a title in front (`John Smith, MD DO DO`), or a credential closed by a period in front (`John Smith, Esq. DO DO`, `John Smith, Jr. DO DO`), the list being by example rather than a census — the count flips the part to the same fields and reports the call, as every flip at this comma does (rules/C1's "A decision either way at this comma is reported"). Those reports are ACCEPTED (Derek, 2026-10-01: none of these is a name anyone would write on purpose, so a report is the right signal): with the capitals no longer settling the run, the call is the count's, and the report says so; `tests/v2/cases.py` pins `John Smith, vd DO`. 1.4.0 read every one of these names as the fix does; 2.0.0 and 2.1.0 read `John Smith, PhD DO DO` as title 'PhD', given 'DO DO', and 2.2.0 and 2.3.0 as the issue describes. MEASURED 2026-10-01 against master 0eadedeb, py3.11, `nameparser.__file__` asserted on each side, each parse compared as its seven fields plus its sorted ambiguity kinds: 0 of the 1453 differential-corpus names move (the two names this change adds are the gate's only movers, under a new fix(#562) rule in the four 2.x ledgers); over tests/v2/test_properties.py's settled grid (5,580 texts) 15 move, 10 of them role moves, and the other 5 (`John Smith, MD DO DO`, `MS`, `Esq.`, `Sr`, `Ms` in front) keep their fields and gain the flip's report; over a wider grid — the prefixes `John Smith, `, `Smith, ` and `Doe, John ` times every run of one to three words drawn with repetition from {PhD, MA, Ma, DO, Do, do, vd, van, Jr, MD, Ms}, 4,389 texts — 30 move, 17 of them role moves, every mover a `John Smith, ` text now reading given 'John', family 'Smith' and the whole part as suffix, and every one reporting `suffix-or-name`. Recompute: check out the parent into a separate worktree, parse each grid in both trees under `PYTHONSAFEPATH=1` with the tree's root first on `sys.path`, and diff. The settled grid's own pin moves with it: tests/v2/test_properties.py's `_SETTLED_COUNT` reads 2,994 where it read 3,024, the ten `_SETTLED_EXCEPTIONS` that pinned #562 are gone, and its two recorded negative controls read 1,142 and 215 — the second had already moved from 751 to 215 with #563, before this change. LEFT OPEN, as #562 asked: `Smith, PhD DO DO` (ONE name word before the comma, so the count keeps the listing form, and P6 attaches the chain: given 'PhD', family 'DO DO Smith'), where no rule states S2's credential-in-front company against a chain; and `John Smith, PhD van der`, whose particles are no members and never reach the run test, reading given 'PhD', family 'van der John Smith' as before. -- 2026-10-01 (Derek) — A CAPITALS-SETTLED RUN OPENED BY A WORD OF THE CLASS REPORTS, AND RULES.MD#C1 NOW SAYS SO. C1 said a run whose every class word is written in capitals "reads whole in silence". That held only behind another credential: a class word OPENING the part is the first word after the comma, whose decision C1 reports either way, so `Doe, MA PhD`, `John Smith, MA MA` and `Smith, MA PhD` read wholly as suffixes and report `suffix-or-name` once, on that word, while `John Smith, PhD MA` and `Smith, PhD MA` are silent. The behavior predates #562 and is kept: nobody repeats `MA` at the end of their name on purpose, so the report is the right signal. Statement corrected, no parse moved. MEASURED 2026-10-01 on master b39c370c, the prefixes `John Smith, ` and `Smith, ` times every run of two or three words drawn with repetition from {MA, BA, ED, DO, JD, PhD, MD, Jr, Esq.} holding at least one class word: all 900 runs opening with a class word report, 895 of them read whole (14 of those hold a #562 particle pair, whose count flip reports over the whole part rather than on the first word) and the other five being `Smith, MA DO DO` and its like, the one-name-word chain #562 left open; of the 560 opening with another credential, 554 read whole in silence, and the other six hold a #562 particle pair — four reading whole and reporting (`John Smith, PhD DO DO`), two being `Smith, PhD DO DO` and `Smith, Jr DO DO`. Recompute: parse that grid and bucket by whether the first word is a class word, whether `ambiguities` is empty, and whether the suffix is the whole part. The same day (Derek) rules/S2's first-slot precedence sentence gained the pointer to that exception, so the next reader does not rediscover it: S2 says the count decides the first slot after a family comma before case, true for one word (`John Smith, MA` flips by the count) and false for a capitals-settled run, where C1's shortcut reads the capitals first and the comma keeps its family reading. And S2's list of reporting slots lost "the segments beyond it": no `suffix-or-name` report lands past the second comma (`Smith, John, MA` and `John Smith, Jr., MA` are silent). It once did: at #530's merge cc78c960 `John Smith, Jr., PhD Do Ma` reported `suffix-or-name` on 'Ma' beside its `comma-structure` flag, from group's chain emitter, which the 2026-09-28 #544 bullet above silenced in a tail. S2 now points at C2 for what such a part does report, which is not only `comma-structure` — an unclosed delimiter there adds `unbalanced-delimiter` (`John Smith, Jr., (Bob`), and a maiden clause standing in it is read as one (`Smith, John, Jr nee Jones MA`, maiden 'Jones MA'). Derek, 2026-10-01: the silence is right. -- 2026-10-01 (Derek), #554 — A WORD OF BOTH THE PARTICLE AND THE SUFFIX VOCABULARY COUNTS IN THE RUN, AND STILL SPEAKS FOR NOTHING. After #544 the run test (`_segment.py`) counts `vd` and `mc` as suffix words, while S2's company (`_pieces._anchors`) refuses to let them speak for the word behind them, an exclusion added within PR #552 after that PR's own anchor pass had brought back a split of `Smith vd Ma, John` into family 'Smith Ma', suffix 'vd'. That split is not only an intermediate state of #552: 2.2.0 and 2.3.0 shipped it (2.0.0 and 2.1.0 read family 'Smith vd Ma'), #530 removed it in this cycle, and #552 kept it removed. The issue framed that as two answers to one question and offered (b): end the run at such a particle, so that one "has a live name reading" predicate decides both. DECIDED (a), the shipped behavior, and no parse moves. They are two questions. Run membership asks what the word IS, and `vd` is unambiguous suffix vocabulary; company asks whether the word SPEAKS FOR the one behind it, and a particle cannot, P2 joining it forward to exactly that word. Both answers follow from the word's vocabulary, and they differ by design rather than by drift; S2's "end the run" is the company's run, not the part C1 counts. The clause reaches exactly `vd` and `mc`: `do`, also in both vocabularies, is in the ambiguous half and so is already a word of C1's class, reporting where these two do not (`John Smith, Jr do` against `John Smith, Jr vd`). Recompute the set: `L = Parser().lexicon; L.particles & (L.suffix_acronyms | L.suffix_words) - L.suffix_acronyms_ambiguous`, and drop the subtraction to see `do` with them. - The evidence is the writing that carries no other signal. Neither reading of `vd Ma` after a comma is realistic, a particle in front of a Chinese family name being as rare as `vd` or `mc` as a credential, so the mixed-case writing is an arbitrary edge case and decides nothing. Written in one case, where S2 has no capitals to read and the vocabulary is all there is, the part reads as the credential run, and that is the reading a reader would take: measured 2026-10-01 on master (fc682e36), `JOHN SMITH, VD MA` and `john smith, vd ma` read given 'JOHN'/'john', family 'SMITH'/'smith', suffix 'VD MA'/'vd ma' and report `suffix-or-name`, as `John Smith, vd Ma` does, and `John Smith, mc Ma` reads the same in every casing. It is also 1.4.0's reading, verified against the released wheel the same day: 1.4.0 read suffix 'vd Ma' and 'VD MA', and 2.0.0 through 2.3.0 read the whole name as the family. (b) would add a particle exception to C1, and to #563's paired-initials test besides, in order to give `JOHN SMITH, VD MA` family 'JOHN SMITH VD MA' with no given name: the worse reading, on exactly the input where nothing else speaks. - #563's site needs nothing of its own. `García Márquez, vd G.J.` reads as `García Márquez, PhD G.J.` does, in every casing: given 'García', family 'Márquez', suffix 'vd G.J.', and neither reports. If a surname split by a credential run in silence is a defect, it is the run's, not the particle's. Open: #573 — uniform-case `vd`/`mc` before a comma and after a family comma (decided 2026-10-02, the P6 section's entry), and the silent mixed-case `Doe, Jane PhD vd Ma` (now #578). Recompute: `parse(s)` with `as_dict()` and `[a.kind.value for a in parse(s).ambiguities]` over each string above and its `.upper()` and `.lower()`. -- 2026-10-01 (Derek), #575 (where the count runs superseded by #613, below) — THE COUNT BEFORE THE COMMA COUNTS A PARTICLE SURNAME ONCE. Every count rules/C1 makes of the part before the comma — v1's "more than one word" for an unambiguous credential, the ambiguous class's name-word count (#289, #544), and assign's two-name-word test for the positional read after a comma followed by no name word (#296/#325) — counted tokens or pieces, so `De La Cruz` was three words and `van der Berg` two (the ambiguous `van` stands as its own piece, P1's fork). This cycle's count had therefore read `De La Cruz, Ed` as a credential comma with no given name and split `van der Berg, MA` into given 'van', family 'der Berg' — an unreleased regression against 2.3.0, which read both as the listing form — and 1.4.0 through 2.3.0 all split `van der Berg, PhD`. DECIDED: a particle run and the one name word it attaches to are one word in all three counts, so a particle surname reads as a one-word surname does. - THE COMMA SETTLES P1's FORK, and that is the decision rather than a side effect. Standing alone, `Van Buren` is given 'Van', family 'Buren' with a `particle-or-given` report, so counting it as one surname makes the comma's reading disagree with the standalone one. Derek's call, after weighing the narrow alternative (count only a part P1 already reads as all surname, i.e. one led by a never-given particle like `de`): the listing form puts a surname before the comma, so the comma is evidence the standalone parse does not have, and the readings it gives are the ones a person takes — `Van Buren, Ed` given 'Ed', family 'Van Buren'; `van der Berg, MA` family 'van der Berg', suffix 'MA' (one name word, so C1 reads the case, and capitals in a mixed-case name make the credential). `Van Johnson, Dr.` moves with it, family 'Van Johnson', title 'Dr.', where 2.2.0 and 2.3.0 read given 'Van' and `tests/v2/pipeline/test_assign.py` had pinned the positional fork; that test now uses `Van Johnson Smith, Dr.`, which still has two name words. - THE PARTICLE REACHES ONE WORD, as P1's fold does, not to the end of the part as P2's chain does: `de Mesnil Jean, Dr.` under a family-first order is family 'de Mesnil', given 'Jean', two name words. The cost, and it is real for an ambiguous particle: a `van`-led part with a word after the surname counts two and keeps the positional read with P1's fork, so `van Buren John, Ed` reads given 'van', middle 'Buren', family 'John' and `van der Berg Smith, PhD` given 'van', family 'der Berg Smith', both unchanged from master. Under the default order a never-given particle loses nothing (P1's fold makes `de Mesnil Jean` all surname anyway). `_vocab.unit_ends` carries both reaches behind a `chain` flag, post_rules' fold reading the full chain as before. - ONLY PARTICLE CHAINS, not mechanisms.md#UNIT-PARTITION's full set — stated there as the Contract's one exception, and in P3's statement, whose "ONE name word wherever another rule counts them" now names C1's count as the exception. A bound given-name pair builds a GIVEN name, and P5 gives up a family word where the name has no other (`abdul Salam` alone is given 'abdul', family 'Salam'), so `abdul Salam, Ed` keeps the credential reading. Whether P3 joins a single-letter connective depends on the words of the whole name — `Carod i Rovira, Josep` joins as four words where `Ortega y Gasset` alone, three, does not — and the count is part of deciding what the whole name is, so segment could reach P3's answer only by copying P3; `Ortega y Gasset, Ed` keeps the credential reading. Approved as "particle chains and connective joins", narrowed to particles when P3's exception surfaced in implementation (the first draft joined `John e Smith, III` into one family name, caught by `tests/test_conjunctions.py`). - A WORD THAT IS ALSO A TITLE IS NO PARTICLE WHERE IT OPENS THE PART. `TITLES ∩ particles` is `{freiherr, st}` and `bound_given_names ∩ particles` is `{abu, أبو, ابو}` (recompute: `L = Parser().lexicon; L.particles & L.titles`, `L.particles & L.bound_given_names`). The first draft counted every particle, and the docs review found it reading `Freiherr von Berg, PhD` as family 'Freiherr von Berg' and `St John, PhD` as family 'St John', where every release reads the title. The first fix then excluded both overlaps in EVERY position, and the review of that fix found two errors in it. Position: inside a surname a title-particle chains as P1 and P2 chain it, and the blanket exclusion brought #575's own defect back for `de St Pierre, Ed`, `De St. Croix, Ed` and `de Abu Bakar, Ed` (family only, no given name, where 2.3.0 read given 'Ed'). And direction: 2.0 through 2.3 read `Abu Bakar, Ed` as given 'Ed', family 'Abu Bakar', so excluding the bound-given particle gave released behavior up rather than keeping it — and Abu Bakar is a common Malay surname. DECIDED: a title-particle is no particle only where it LEADS the part, and a bound given-name particle is a particle. So `Freiherr von Berg, PhD` and `St John, PhD` keep the title, `de St Pierre, Ed` reads given 'Ed', and `Abu Bakar, Ed` reads given 'Ed', family 'Abu Bakar' while `Abu Bakar, PhD` reads family 'Abu Bakar', suffix 'PhD' where 1.4.0 through 2.3.0 read given 'Abu'. The agreement test compares each token in both positions; it cannot see which position a caller passes, so case rows pin each count's flag: `de St Pierre, Ed` segment's, and `St John, Dr.` and `von St Johann, PhD` assign's, one per direction (the review of this round found assign's flag fixed to either constant passing every test, and both mutants now fail). `Freiherr von Berg, Ed` reads family 'Freiherr von Berg', given 'Ed' — 2.0.0's through 2.3.0's reading too, so no change against a release; only this cycle's count had read title 'Freiherr', family 'von Berg', suffix 'Ed'. The surname counts once and the title is no name word, so the count reads the listing form, which keeps a leading title in the family as it already did for `Prof. Cruz, Ed` (superseded for listed titles by #H6, 2026-10-17: the count is unchanged, and the listed title then peels). For `Prof.` that is a cost (rules/C1's Accepted line); for a German rank it is the right reading (Derek, 2026-10-01): since 1919 a former noble title is part of the legal surname, written between the given name and the particle (Karl-Theodor Freiherr von und zu Guttenberg), so `Freiherr von Berg` is the family name. That is also why `freiherr` is in PARTICLES at all — added with the German prefixes in 3e14ea20 (#18) without a stated reason, and the reason is this one. `Freiherr von Berg, PhD` keeps title 'Freiherr', the unambiguous credential's count taking the title as a word; both are readings of one name, and neither is changed here. A title in front is otherwise outside the fix: v1's count for an unambiguous credential counts the title as a word, so `Dr. van der Berg, PhD` reads as `Dr. van der Berg` does alone, given 'van'. - `De La Cruz, M.J. K.L.` reads given 'M.J.', suffix 'K.L.', reporting twice, as `Cruz, M.J. K.L.` does (Derek, 2026-10-01): with one name word before the comma the paired-initials count no longer reaches it, and C1's two-dotted-groups sentence now says "behind two or more name words". #563 had read it as a flipped credential run with no given name. - SEGMENT RUNS BEFORE CLASSIFY, so its count builds the two facts it reads (particle, suffix) from the vocabulary (`_vocab.surname_unit_tags`), while assign derives them from classify's tags (`_vocab.surname_unit_facts`) over every token of the part, so a suffix word stops a particle in both: the first draft's assign count dropped suffix pieces before walking and read `van Jr. Berg, Mr.` as family 'van Berg' (the code review). `test_classify.test_surname_unit_tags_agree_with_classify` sweeps every single-word vocabulary entry in three casings; its first run caught `JD.CPA` and `Msc.Ed.`, which classify tags as suffixes through the period-joined derivation, now mirrored, and that pair is the recorded negative control (`_SURNAME_UNIT_CONTROL`, asserted with the mirror patched out). - BLAST RADIUS, measured 2026-10-01: the gate exits 0 at all five baselines, and of the 1459 names in master's corpora exactly one moves, `De La Cruz, M.J. K.L.` (above, decided). Comparator: `parse(n).as_dict()`, the ambiguity kinds and `initials()` for every name in every `tools/differential/corpus*.jsonl` at `git archive origin/master`, under all three name orders, on master's tree against this one. The population that could move is small and the corpus is evidence about itself, not the rule: two corpus names have a part before the comma that is one particle surname of several words, with the part after the comma holding a credential — that one and `De La Cruz, M.J. PhD`, which keeps given 'M.J.'. Recompute: corpus names whose part before the first comma has more than one token and `_vocab.surname_unit_count` 1 (24 at master, nearly all `de la Vega, Juan`-type listings), then keep those whose second segment holds a word `_vocab.ambiguous_class_candidate` admits (a listed or dotted member of the credential class) — 2 at master. A filter on any suffix word keeps 17, the `de la Vega, Juan … III` listings among them. Against 2.3.0, `fix(#575)` classifies one name, `van der Berg, PhD`; the other particle-surname example names diff there only by this cycle's #289 count and report. Outside the corpora the move against 2.3.0 is a class, not a list: a leading ambiguous particle and one word, before a comma followed by an unambiguous credential or a title alone, now reads as one surname (`Abu Bakar, PhD`, `bin Laden, PhD`, `Mac Donald, PhD`, `van Gogh, Jr.`, `Van Johnson, Dr.`), where 2.3.0 read the particle as the given name; `Freiherr von Berg, Ed` and `Abu Bakar, Ed` move only against this cycle's master. The boundary case rows added in review carry no shape tag on purpose: their diffs against the older baselines come from earlier changes (#296's positional read, #289's count), so admitting them to the contract corpus would have stretched unrelated ledger rules over them; the case table asserts them either way. - COST, measured 2026-10-01 by `sys.setprofile` call counts (mean of 50, after one warm-up) against `git archive origin/master`: `Smith, John` 183 → 183, `John Smith, MA` 252 → 252, `John Smith, PhD` 209 → 217, `Dr. Juan de la Vega III` (the benchmark reference) 366 → 366. The +8 is the unambiguous credential path building its token list and asking each token whether it is a particle; a part with no particle never builds the units, and a frame-free approximation of `_normalize` was declined as a second spelling of it. -- 2026-10-01 (Derek), #564 — AN UNLISTED ALL-CAPS WORD IS A CREDENTIAL IN THE PART AFTER A COMMA BEHIND TWO NAME WORDS, BY DEFAULT. The decision, its two-letter rule and the mixed-run membership are recorded under decisions/S9 (#564), where the caps shape's switch has always been decided; this bullet is C1's pointer to it. -- 2026-10-03 (Derek), #549 — A DECLARED DELIMITER PARTS A TRAILING SUFFIX PART AS A COMMA WOULD, AND THIS REVERSES #538'S SKIP READING (M2, 2026-09-26). The question #549 asked was whether a core a connective join swallows should be skipped (the text read as if the core were absent) or should end the run it stands in; Derek's ground for the second was the one a caller has when declaring one: a delimiter set to ` - ` is meant to behave like a comma around suffixes, and the simplest parser that honours that is one where it IS a comma there. So `group` cuts a tail segment at its cores before grouping anything, groups each part as the comma twin's own segment would be grouped, and drops the cores, which post_rules' entry pass still reads back as boundaries. No join, maiden walk or link search can reach across a core because none of them ever sees one, and the machinery that let them step over it is gone: the `cores` parameter of `_group_segment`, `_maiden_take`, `_run_neighbours` and `_link_joins_inside_the_clause`, `_maiden_take`'s `seen` index list, the `skip` arguments of `peel_walk` and `trailing_start`, and the #206 drop block that ran after the joins. One call per parse fewer (`tools/perf/call_count.py`, py3.11, 2026-10-03: parse 369 against 370 at 061f02da). - WHAT MOVES, against 1.4.0 and against 2.3.0. A connective beside a core no longer joins across it: `HumanName("Smith, John, PhD - and MD", suffix_delimiter=" - ").suffix` is 'PhD, and MD', and `... Puig - y Soler` 'Puig, y Soler' -- 1.4.0's answers (its `expand_suffix_delimiter` split the part's text before anything joined), where 2.0.0, 2.1.0, 2.2.0 and 2.3.0 each gave 'PhD - and MD' and 'Puig - y Soler' (all measured 2026-10-03 with the released wheels). The generational link moves back too: 2.3.0 read `Smith, John, Puig - i Soler` as 'Puig, i Soler' and `Smith, John, PhD née Puig - i Soler` as maiden 'Puig', suffix 'PhD, i Soler', `i` being no connective there, and #397/#538 had moved them to 'Puig - i Soler' and maiden 'Puig i Soler' before either shipped; both read as 2.3.0 did again. An interior core still parts: `... PhD née Puig Dr. i - y Soler` gives suffix 'PhD i, y Soler'. A MAIDEN MARKER BESIDE A CORE TAKES NO CLAUSE, and this is the larger cost by count: with the core straight before the marker (`Smith, John, MD - née Jones Smith`) or straight after it (`Smith, John, PhD née - Jones`), the comma twin's marker has no name word ahead of it in its part, or nothing behind it, and M2 declines, so the parse reads suffix 'MD, née Jones Smith' and 'PhD née, Jones' with no maiden, where 2.0.0 through 2.3.0 read maiden 'Jones Smith' and 'Jones' (measured 2026-10-03; 1.4.0 had no maiden markers and gave the suffix reading). It is the one rules/C1 records as Accepted. At the default policy nothing moves, `extra_suffix_delimiters` being empty: the gate exits 0 at all five baselines. - THE DECLINED READING, TAKEN. #538 declined the boundary reading because it moves `Smith, John, PhD née Puig - i Soler` from maiden 'Puig i Soler' to maiden 'Puig' with suffix 'PhD, i Soler', "a birth-name link pushed into the credentials". That is what the same text with a comma typed in the core's place reads, and it is what 2.3.0 read. The marker beside a core (WHAT MOVES, above) is the same trade at a higher price, a maiden name lost rather than a link moved, and it is taken on the same ground; the review of this change found it, the first draft of this entry having weighed only the link shape. The comma reading is accepted as the cost of one rule for a declared separator, where the skip reading kept two (a lone core dropped like a comma, a core beside a connective stepped over like a word) and #549 was the seam between them. - INVARIANT, pinned by `tests/v2/test_properties.py::test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin`: every ROLE field of a name with a ` - ` core in a part after the suffix comma equals that of the same text with a comma in its place, over 129 generated texts; 102 disagreed at 061f02da, 0 after. It replaces #538's `test_a_delimiter_core_reads_as_if_it_were_not_written`, whose invariant this decision reverses. Scoped to TRAILING parts on purpose: before the first comma, or in the given part of the listing form, the core is a word (C1's Accepted entry, v1 parity), and a comma typed there changes the name's structure rather than parting a suffix run, so the twin is a different parse. The ambiguity reports are not part of it: C2 reports `comma-structure` once per extra comma part, so a typed comma can add a report the core does not (`Smith, John, Puig - Puig` against `Smith, John, Puig, Puig`). - MEASURED 2026-10-03, branch against 061f02da. POPULATION: each head in {`Smith, John,`, `Smith, John, Jr.,`, `John Smith,`, `John Smith`} followed by every 2-, 3- and 4-word product of {PhD, MD, Puig, i, y, -, Jr., Soler, Mr., née, and} that contains `-`, 19,972 texts, plus the 1,505 distinct names of `tools/differential/corpus*.jsonl` at 061f02da. Under `extra_suffix_delimiters=(" - ",)` 2,624 of the generated parses move (suffix alone 2,384, suffix and maiden 240 -- every one of the 240 a maiden name lost to a marker beside a core: 100 with `née -`, 108 with `- née`, 32 with both); under `(" / ",)`, with every `-` word rewritten to `/`, the same 2,624 (2,384 and 240), the move being a property of the core's position rather than its spelling; and under `(" and ",)` over the texts as written, where `and` is now the core and the dash a word, 2,528 (2,468 and 60). No other field and no ambiguity report moves under any of the three. In the corpus, one name moves under each of two policies: `Smith, John, PhD née Puig - i Soler` under ` - ` (above), and `Doe, Jane, and Jr.` under ` and `, suffix 'and Jr.' to 'Jr.', a leading core dropped as any lone core is. COMMA-TWIN AGREEMENT over the generated texts under ` - `, comparing the seven role fields of each against the text with ` - ` replaced by `, ` under the default policy and keeping only texts whose core is neither first nor last after the head nor beside another core: `Smith, John,` 752 of 2,100 disagreed before and 0 after, `Smith, John, Jr.,` 752 of 2,100 and 0; `John Smith,` 1,956 of 2,100 both before and after, the head where the typed comma moves the name's structure. (A first draft counted 973 of 3,310 for the `Jr.,` head, its filter reading `Jr.,` as part of the body; the review of this change caught it.) RECOMPUTE: parse each text under each policy at both trees (pin `nameparser.__file__` on each side, AGENTS.md's two-tree gotcha), compare the seven role fields and the sorted ambiguity kinds, and count the texts whose record differs; for the twin, compare the role fields alone. - SUPERSEDES, under M2: the 2026-09-26 #538 entry (its stepping, its invariant and its Not-repaired list, which this closes); the 2026-09-22 #397 follow-up's bound argument (a core can no longer stand between a marker and the clause's first word, the segment being cut at it first); and the #418 reorder entry's closing repair, which screened a tail's cores out of the marker pass so that `Smith, John, PhD née - Jones` read maiden 'Jones' -- it reads suffix 'PhD née, Jones' now, as above. Entries there that describe the walk stepping over a tail's cores (the #424 and #533 bullets) describe machinery this change deletes; they are dated and left as written. Not changed: a core OUTSIDE a tail segment is still a word (C1's Accepted entry), and a core inside a token (`RN/CRNA` under `/`) still reads by `splits_into_suffixes` and keeps the token whole. - -- 2026-10-04 (Derek), #603 (decided at group's head since #613, below) — A CREDENTIAL OPENING THE PART AFTER THE COMMA MAKES IT THE POSTNOMINAL PART, WHATEVER FOLLOWS; AND A TITLE WORD PAST THE SECOND COMMA IS A TITLE. One word that is neither suffix nor title vocabulary used to flip the whole comma reading: `John Smith, PhD Jones` read family 'John Smith', given 'PhD', where `John Smith, MD FACS` and `Eric H. Holder, Jr. Attorney General` were already read as a name and its postnominals. Now the first suffix word of the part, with only titles in front of it, opens the part when it starts S2's run (`_pieces.starts_a_credential_run`, #602's own predicate, so the two runs cannot disagree about what starts one), and every word after it reads as that run reads: a title word as a title, any other as a suffix. The mechanism is the one #296 built for a part holding no name word, extended rather than added beside: `segment` still decides FAMILY_COMMA, and `segment_suffix_reading` returns a reading instead of None, so assign's no-name path reads the part before the comma positionally where it holds two name words. Every word the opened part takes that would otherwise have made it the given part is reported (`suffix-or-name`), as #602's run reports what it absorbs. - THE ONE-WORD PATH, DECIDED (Derek): the whole part is suffixes. `Doe, PhD Jones` reads family 'Doe', suffix 'PhD Jones', no given name, as `Smith, PhD` already reads. DECLINED: reading the credential as a suffix and the next name word as the given name (`Smith, PhD Jones` → given 'Jones'), proposed by analogy with a title at the head of the given part. Asked whether that is a real format, it is not -- nobody writes `Family, Credential Given` on purpose -- and the one realistic input it served, a misplaced generation (`Smith, Jr. John` for `Smith Jr., John`), is garbage in, garbage out. One reading for both comma paths was the deciding ground: the one-word path disagreeing with the full-name path is the shape #429, #430 and #432 each found. Accepted cost: `Smith, Jr. John` loses 'John' to the suffix, where 2.3 read given 'John' with 'Jr.' misfiled as a title. - A TITLE/SUFFIX DUAL OPENS NOTHING (Derek). A word in both vocabularies (`md`, `ms`, `sr`, the ranks `lt`, `cpl` and the rest) is the listing form's title in front of a given name, `Smith, Ms Jane`, and a credential behind it opens nothing either (`Smith, Ms PhD Jones` keeps given 'PhD', as `Smith, MD PhD Ma` keeps given 'Ma' under #544). DECLINED after building it: a dual opening the part behind two or more words before the comma. Derek first chose it for `John Smith, MD Jones`; the reviews found what the count cannot see -- a surname of two words (`García Márquez, Ms Gabriela`, `Smith Jones, Sr. Maria`), a title in front of a one-word surname (`Dr. Smith, Ms Jane`, the count taking the title as a word), and spaced initials (`García Márquez, Ms G. J.`) each lost the given name -- and with the word explained (a dual is a word in both lists, decided by position), Derek chose that duals never open. So `John Smith, MD Jones` and the `MD - PhD - FACS` delimiter rows keep their 2.3 reading. An ambiguous-class member opens nothing either (`John Smith, Ma Jones` keeps given 'Ma'), nor does a suffix word S2's run start refuses (`John Smith, vd Jones`). - STRICT MODE: the veto refuses an initial-shaped word the suffix READING, not the run. `Smith, PSM I.` under `lenient_comma_suffixes=False` reads suffix 'PSM I.' (2.3: given 'PSM', suffix 'I.'), reporting the absorbed 'I.', which matches #602's no-comma run (`John Smith PhD V.` reports; its given-part run does not, a disagreement #602 left and this does not touch). - THE SPELLING GAP THIS FOUND, FIXED HERE. `starts_a_credential_run` refused any piece longer than one token, so the split credential `Ph. D.` group merges started neither #602's run nor this one while `PhD` started both: `Smith, John Ph. D. Jones` read middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones'. The merged piece now starts the run, the "suffix" piece tag being the Ph./D. merge's alone; `trailing_candidates` had already admitted it, so its pre-check and the predicate now agree. The no-comma spelling `John Smith Ph. D. Jones` still reads family 'Jones': the trailing peel's walk never holds the merged piece, which is a separate path and is left (AGENTS.md's spelling sweep). - C2, WIDENED (Derek): a word of the title vocabulary in a part past the second comma that is not also suffix vocabulary is a title -- the part stands behind a comma, the line H5 draws for reading a title from the end of a name -- and a piece holding a suffix word stays a suffix, a dual and a connective join that took one (`PhD - and MD`) alike. `Eric H. Holder, Jr., Attorney General` reads title 'Attorney General', and a part of title and suffix words is recognized rather than flagged. The flag's test is asked word by word against the title vocabulary, a period-joined word through `period_joined_vocab` as classify tags it (`John Smith, Jr., Lt.Gov.`, which the code review found still flagged), so `Secretary of State` reads its title but keeps `comma-structure`: knowing that group's title chain takes the connective would be `segment` modelling `group`, the sign #611 is about. Every release through 2.3.0 read these as suffixes, 1.4.0 included (`Eric H. Holder, Jr., Attorney General` suffix 'Jr., Attorney General', `Smith, John, Prof.` suffix 'Prof.', measured with the released wheels 2026-10-04); a first draft of this entry said 1.4.0 read them as titles, inferring it from the 1.4.0 run reporting no unexplained diff, when the reason was the broad `fix(comma-family)` rule there claiming them, which #603's two rules now precede by declaration. Accepted consequence: a noble rank there reads as a title, `John Smith, Jr., Freiherr von Richthofen` → title 'Freiherr', suffix 'Jr., von Richthofen'. - WHAT MOVES, measured 2026-10-04 against master at 97d36e0d, after the dual decision above. The differential corpora at 97d36e0d (1,519 name and order entries over 1,515 distinct names): four names, every one intended -- the dash-delimited `Steven Hardman, RN - CRNA` now reads given 'Steven', family 'Hardman', suffix 'RN - CRNA', and `John, Smith, Dr.`, `Andrew Perkins, Jr., Col. (Ret)` and `1 & 2, 3 4 5, Mr.` read their tail title. A generated grid: every name of `tools/differential/corpus*.jsonl` at 97d36e0d, bare-string and object lines alike, plus 120,000 one-to-seven-word texts drawn with `random.Random(601)` from the 54 words {Jane, Doe, Smith, John, nee, née, geb., z domu, PhD, MA, Ma, MD, Jr., Jr, III, V, VI, i, y, de, de la, van, vd, mc, do, Do, DO, abd, abdul, Dr., Prof., King., Le, Attorney, General, Jones, `,`, ba, R.A.I., (nee, Smith), Ó, binti, Ph., D., Chief, Justice, Ms, Sr., RN, -, G.J., MJ, Col.}, plus each of the heads {Jane Doe, Doe,, Smith, John, John Smith, Dr., Mai Le, Jane van der Berg} followed by every three-word product of {nee Smith, PhD, MA, Prof., de, Jr., Jones, VI, do, i Soler, nee}; 98,630 texts, each parsed under the default policy, `name_order` family-first and a ` - ` delimiter, the seven role fields, the ambiguity kinds and details and `initials()` compared against 97d36e0d. 589 texts move: 581 have a part after a comma whose first non-title word is a suffix word that is not also a title, or a title word past the second comma, or both; one, `Jr. i Ó VI Attorney, Ph.`, is the two-name-piece count reading a connective-joined piece beside a suffix word as the family (title 'Ph.', family 'Jr. Ó VI Attorney', suffix 'i', where master read title 'Jr. Ph.', given 'i'); the other seven were read by hand -- a leading period-shape title (`geb.`) in front of the opening credential, and #602's given-part run started by `Ph. D.` (`, abdul Ph. D. Do binti Smith`). Frames per parse through `Parser().parse`, py3.11: the reference band and plain names unchanged (`Dr. Juan Q. Xavier de la Vega III` 449, `Smith, John` 179, `John Smith, Jr.` 210, `Smith, John, Jr.` 223, `Smith, Ms Jane` 256); the positional count gets cheaper, leaving in C for a single piece and building its name set in a loop rather than a comprehension (`Smith, Jr.` 163 → 156, `Smith, MD PhD` 260 → 253, `John Smith, Dr.` 261 → 260); and the opened part pays for what it reads (`John Smith, PhD Jones` 338 → 361). Measured after the /simplify round, which also made the count ask for two name PIECES as well as two units (`Vega y Lopez Jr., PhD Jones` had lost its family). - 2026-10-04 #611 — C1, S2 AND P6 GREW BY PATCHING, AND EACH SHOWS THE DECISIVE SIGN; THE RETHINKS ARE FILED, NOT DONE. The question was docs/design/AGENTS.md's, asked of the three longest rules after M2 (#601): does a guard model what another stage would do? C1: `segment` predicts group's particle chain (the #562 pair test) and assign's capitals reading (the skip #562 broke), mirrors classify's tags in its unit counts, and decides the structure in two stages, assign re-deciding #296's positional read with a second count -- the copy #603 extended. S2: the words-to-spare count runs over pieces group merges, so the chain re-asks the peel and rolls back (`John van Mc`), P5 compares two views, group emits S2's report, and the peel runs up to five times a parse. P6: assign holds back exactly the particle tail post_rules will attach (`particle_tail(..., sticky_from)`). Decided: #603 shipped as a targeted change inside today's machinery; the rethinks are #613 (C1, read the comma once after classify and bind, P6 folded in) and #614 (S2, read the trailing run once over units and bind), C1 first because S2's unit count should be shared with it, neither for 2.4. - 2026-10-06 (Derek), #613 — C1 IS DECIDED ONCE, AFTER CLASSIFY, AND THE PART IT DECIDES IS BOUND BEFORE ANY JOIN. #611 found the decisive sign in C1: segment decided the comma structure before any word was tagged, so it modelled what later stages would do -- group's particle chain unsettling a run the capitals settled (#562's pair), assign's reading of a capitals-settled run -- and assign re-decided #296's positional read with a second name-word count. The decision now runs at the head of group (`_comma.decide`), once classify has tagged every word: the part after the first comma is read ONCE by `segment_suffix_reading`, the reading a part holding no name word gets, with the ambiguous words the count licenses passed in, and a postnominal part's SUFFIX and TITLE roles are bound there, so no later stage can read it differently (mechanisms.md#READ-WITHOUT-THEN-BIND, its second instance after M2's #601). segment hands every comma form over as FAMILY_COMMA and keeps C2's flag; script_segment, which runs between, asks its own narrower question (`_postnominal_behind_a_whole_name`, the vocabulary-only suffix test behind two surname units, its peeled tail excluded). Deleted with it: segment's decision block and #564's contrast (moved into `_comma`), classify's comma-run caps branch, assign's comma reading, positional count and second report, `_vocab.run_word_fold`, and the settled-run mirror test in `test_properties.py`, whose whole job was to hold segment's model of group equal to group -- with one decider there is no model left to hold. DECIDED (Derek, 2026-10-06), each measured before the code was written: (1) a CONNECTIVE surname counts its words before the comma, as #575 decided for every surname but a particle chain -- `Ortega y Gasset, Dr.` reads title 'Dr.', given 'Ortega', middle 'y', family 'Gasset' (b547c247: family 'Ortega y Gasset'), and PR #615's Vega rows, which had pinned the connective as one unit, reverse; (2) nothing after the decision unsettles what it bound, so #562's pair report goes silent (`John Smith, PhD vd DO` keeps suffix 'PhD vd DO' and loses `suffix-or-name`) and a part of credentials holding particles side by side is the credential run behind any name (`De La Cruz, DO DO DO` reads suffix 'DO DO DO', b547c247 given 'DO', middle 'DO DO'); (3) no particle run, connective join or bound given name reaches into a part read as postnominal -- `John Smith, Ph. D. and Mary Jones` reads suffix 'Ph. D. and Mary Jones' (b547c247: family 'John Smith', given 'Ph. D. and Mary', middle 'Jones'), `De La Cruz, MA DO Mr.` reads suffix 'MA DO', title 'Mr.' (b547c247: given 'MA', middle 'DO Mr.', the chain having taken the title), and the one join kept inside the part is P3's connective binding title words into one title (`John Smith, Mr. and Mrs.` keeps title 'Mr. and Mrs.', `_comma._title_chains`; the first draft lost it, and the corpus now carries the name); (4) the positional read of a name before a suffix comma reports as the comma-less name does (`Freiherr von Berg, DO` gains the chain's `particle-or-given`, as `Freiherr von Berg` reports), and a part before the comma that is one name word with a suffix in it is the listing form's family rather than a positional read: `Smith Jr., Esq.` reads family 'Smith', suffix 'Jr., Esq.', and `Smith Jr., V` given 'V', family 'Smith', suffix 'Jr.', as `Smith Jr., John` reads (b547c247: given 'Smith' with `given-or-family`, and given 'Smith', suffix 'Jr., V'). ACCEPTED WITH THEM, found in measurement or review rather than decided ahead. A connective between a credential and a title joins nothing, since it takes its kind from the credential in front: `John Smith, PhD and Dr.` reads suffix 'PhD and', title 'Dr.' with a report (b547c247: given 'PhD and Dr.'), garbage either way, pinned as the cost of (3). The split credential written around another word is not merged, merging being for adjacent halves: `John Smith, Ph. Ma D.` reads title 'Ph.', given 'Ma', middle 'D.', family 'John Smith' (b547c247: suffix 'Ph. Ma D.'), garbage too. A LISTED member written with a period reads as it does without one behind a whole name, the count licensing it before the lone-piece route's period inference can (`John Smith, Ma.` suffix 'Ma.' as `John Smith, Ma`; b547c247 title 'Ma.'), and `John Smith, PhD Ma.` reports the count's flip over the part where b547c247 reported the anchored pick, as `John Smith, PhD Ma` always did. The garbage `Ph. D., Jr.` reads family 'Ph. D.' (b547c247: title 'Ph.', family 'D.'), its watched row re-recorded in compare.py. THE CJK RADAR TIER, settled in review and then by Derek. script_segment runs before group, so it cannot read decide's answer, and it needs one: whether the part after the comma is the postnominal part of a whole name decides whether the name before it divides and where a glued honorific peels from. The first draft asked a hand copy of C1 there, by the vocabulary alone, and the copy drifted from decide in four review rounds -- the ambiguous class's count (`김민준 박, MA` read as a credential run behind a two-word name while the name stayed whole), decide's numeral refusal and policy-selected suffix test (`김민준 박, MA V` divided behind a family comma, family '김 민준 박'), a suffix before the comma counted as a name word (`김민준 Jr., PhD` divided, the division then making decide agree), and the split `Ph. D.` (`김민준 Ph. D., PhD`) -- each drift dividing a name decide read as a family comma, or leaving whole one it read as a suffix comma. DECIDED (Derek, 2026-10-06, the /simplify pass): script_segment asks `_comma.decide` itself, on a classified copy of the name as written, and only where the answer can change something (the peel once its two candidate sites differ, the division once there is a token to divide), so no Latin or accented-Latin comma name pays for it. The division now follows decide in both directions (`test_the_division_never_outruns_the_comma_decision`: on 3320d700, the last tree with the copy, 0 of its 300 parses divided behind a family comma and 44 stayed whole behind a suffix comma; now 0 and 0). Against b547c247 that restores every reading the copy had moved (`Smith 김민준씨, XYZ`, `John 田中さん, XYZ`, `김민준 박, X.Y.Z.` divide and peel as master read them), and moves one class master read otherwise: a title and one CJK name word before a credential the capitals license, which decide reads as a suffix comma -- `Dr 김민준, MA` reads title 'Dr', given '민준', family '김', as `Dr 김민준` does (b547c247: family '김민준', the division declined and H1 making the one word the family), and the pre-existing tolerated row `Dr. 김민준씨, MA` moves with it; so does a glued honorific in a part decide reads as postnominal, `John Smith, Dr. J.씨` reading title 'Dr.', given 'John', family 'Smith', suffix 'J.씨' as `John Smith, J.씨` always did (b547c247 peeled the 씨 from the part and read given 'J.', family 'John Smith'). Measured on the /simplify pass's 78,282-parse fingerprint: 1,106 parses move, every one a CJK name before the comma or a `J.씨` after it. `마틴 킹, MA` reads as master did; its report counts three name words, decide reading the divided name. `_vocab.surname_unit_count`, which only the copy asked, is deleted. - DELIMITER CORES are dropped only behind a whole name, which is C1 Accepted's v1 parity (`Smith, MD - PhD - FACS` keeps its cores): the first draft dropped them behind a one-word family too. - WHAT MOVES, measured 2026-10-06 against b547c247 (PR #615's merge), py3.11, with the review round's fixes in. The harness: every differential corpus name at b547c247 plus a comma grid -- for each head in `Smith`, `John Smith`, `van der Berg`, `García Márquez`, `De La Cruz`, `Dr. Smith`, `John Q. Smith`, `SMITH`, `JOHN SMITH`, `Smith Jr.`, `Vega y Lopez`, `LLOYD WEBBER`, `abdul Salam`, `john smith` and `Freiherr von Berg`, the forms `H, A`, `H, A, Jr.` and `H, John A` for every A in `PhD MD Jr. III V V. I MA Ma ma Ed DO Do vd de X.Y.Z. XYZ LEED AP G.J. MJ Ms Mr. Dr. Prof. Jones John Q. Ph. D. Esq. RN - nee i y Sr. and MEng 씨`, `H, A1 A2` for every ordered pair of those, `H, C1 C2 C3` and `H, C1, C2` over `PhD MA Ma DO vd G.J. MJ Ms Mr. Jones V XYZ Jr. Ph. D.` -- 80,423 distinct texts, each parsed under the default policy, `lenient_comma_suffixes=False` and a family-first order; comparator the role fields, the ambiguity kinds and details, and `initials()`. 3,631 texts move, 1,773 with a role. Under the default policy the 1,739 role movers fall in the classes above and nowhere else, sorted by the first of these tests a text meets, in this order (the classifier is shape-only, so a later class can hold texts an earlier one would have taken): `Ph.` and `D.` in the part with a word between them, 31 (ACCEPTED); a connective `y` or `i` in the part before the comma, 936 (1); a part before the comma ending in a suffix word, 456 (4); a connective `and`, `y` or `i` in the part after it, 65 (3); a particle (`do vd mc de van`, any case) in the part after it, 250 (2, 3); and `Ph. D., Jr.`. The report-only movers, compared IN ORDER: 1,177 gain `particle-or-given`, every one the `Freiherr von Berg` head (4); 166 lose `suffix-or-name` (2's pairs) and 4 lose `given-or-family` (`Smith Jr., Jr., Mr.` and its kin, 4); 182 keep the same reports in a new order, `comma-structure` now written before the comma's `suffix-or-name` because segment emits the former and the decision runs after it (report order is no contract, and nothing pins it); 266 change a kind or a detail, the shapes being the first-word report taking the place of the count's flip report on a part a class word opens (`García Márquez, DO DO`), the absorbed-word report no longer naming a particle the chain had joined to the word (`De La Cruz, PhD DO D.` reports 'D.' where it reported 'DO D.'), and the count's flip taking the place of an anchored pick (`John Smith, PhD Ma.`); 63 move under a non-default policy alone, 34 of them in a role. The corpora at b547c247 (1,526 distinct names): four move -- `John Smith, PhD DO DO` and `John Smith, PhD vd DO` lose (2)'s report, `Ph. D., Jr.` as above, and `마틴 킹, MA` in its report detail alone. The gate exits 0 at all five baselines; intentional diffs 631 → 632 at 1.4.0 and 285 → 286 at 2.3.0, the corpus having gained `Ortega y Gasset, Dr.` and `John Smith, Mr. and Mrs.` as C1 examples, and radar unclassified gains `Ph. D., Jr.` at both. - COST, `sys.setprofile` call counts after one warm-up, b547c247 → the tree with this entry's P6 fold and review fixes in (the P6 bullet under P6 gives the fold's own share): `John Smith` 151 → 151, `Smith, John` 179 → 160, `Smith, John Q.` 246 → 230, `Smith, Jr.` 156 → 156, `Smith, John, Jr.` 223 → 204, `John Smith, MA` 249 → 253, `John Smith, Dr.` 260 → 212, `Smith, MD PhD` 253 → 185, `John Smith, PhD Jones` 361 → 262, `Doe, Jane nee Smith` 309 → 293, the reference parse `Dr. Juan Q. Xavier de la Vega III` 449 → 449. A no-comma name never enters `decide`, decide returns before C1's counts where the part holds a name word and nothing of the ambiguous class (guarded by `test_a_listing_comma_with_nothing_to_decide_skips_the_counts`), and group filters its segments for bound roles only when `decide` bound something. The paired-initials speaker scan came back quadratic in the move (`is_suffix_piece` 120 → 1,620 calls for 4× the input on `'John Smith, ' + 'MD '*k + 'G.J. '*k`) while #563's guard, which counted `_normalize`, passed; the scan now finds the speaker once, and the guard counts what the scan calls. + DELIMITER CORES are dropped only behind a whole name, which is rules/C10's Accepted v1 parity (`Smith, MD - PhD - FACS` keeps its cores): the first draft dropped them behind a one-word family too. + WHAT MOVES, measured 2026-10-06 against b547c247 (PR #615's merge; this change is PR #616), over every corpus name and an 80,423-text comma grid under three policies: 3,631 texts move, 1,773 in a role, and every default-policy role mover falls in a class decided or accepted above (a connective before the comma 936, a suffix ending the part before it 456, a particle after it 250, a connective after it 65, the split `Ph.` … `D.` 31, and `Ph. D., Jr.`); the report-only movers are (4)'s `Freiherr von Berg` gains, (2)'s lost pair reports, and reports reordered or reworded. Four corpus names move, and the gate exits 0 at all five baselines. + COST, `sys.setprofile` call counts against b547c247 (PR #616): the reference parse and `John Smith` are unchanged and the comma shapes measured cost fewer frames or up to four more (`Smith, John` 179 → 160, `John Smith, PhD Jones` 361 → 262, `John Smith, MA` 249 → 253). The paired-initials speaker scan came back quadratic in the move while #563's guard, which counted `_normalize`, passed; the scan now finds the speaker once, and the guard counts what the scan calls. THE PR REVIEW, after the two rounds above. An EMPTY part before the comma had decide return early, leaving the part to the listing walk: `, PhD` read given 'PhD', `, Jr.` title 'Jr.', `, MD PhD` title 'MD', family 'PhD', where b547c247 and 2.3.0 read suffix -- decide now reads the part behind an empty head, which names no whole name, so the comma stays the family comma with the part bound (the heads of every grid above were non-empty, so none saw it). And three more costs the frame guards could not see, each now a clock row in `_PREFIXED_SHAPES` (base 1600, broken 9.9-11.9 against fixed 4.0-4.3): `_title_chains` rebuilding the growing title from a slice at each join, the paired-initials test copying the shape set per pair and scanning a list of the class members, and the core filter scanning a list of the cores. And one render view: decide cleared its caps-shape marks whenever C1's run test declined, so a part #603's opener bound anyway kept its all-caps word without them, and case repair wrote `John Smith, PhD Jones XYZ` as '... PhD Jones Xyz' (rules/R4); the marks now stay on every bound caps word that carries the name's contrast, roles and reports unmoved over the harness (`test_a_bound_caps_word_keeps_its_capitals_in_case_repair`). - SUPERSEDES: the 2026-10-01 #562 bullet's mechanism and its accepted reports (its pair report and the settled-run test); the 2026-10-04 #603 bullet's "segment decides" wording; and the #575 bullet's account of WHY segment builds its unit facts from vocabulary -- `_comma._whole_name` counts over classify's tags, and `name_word_count`, which reads the vocabulary, is asked by decide and by script_segment, the agreement sweep `test_surname_unit_tags_agree_with_classify` keeping the vocabulary view equal to the tags. -- 2026-10-06 #617 — CROSS-REFERENCE: the title join decision (3) above makes inside a part read as postnominal is no longer `_comma._title_chains` but group's own loop, `_pieces.join_connectives`, and it now merges a connective run first (`Smith, Chancellor of the Exchequer MP` reads suffix 'MP') and freezes the generational class; decided and measured in decisions/P3's 2026-10-06 #617 entry, which amends (3). -- 2026-10-11 (Derek), #636 — NO PARTICLE CHAIN IS MADE IN A PART THAT HOLDS NO NAME WORD. A part past the second comma, after either comma, is read wholly as suffixes, its title words as titles (C2, #603), but group still ran the particle chain over it, which builds a name unit no reading uses (the part after a suffix comma, which C2 covers too, had reached no chain since #613, `_comma.decide` binding it and emptying its segment before any join; a suffix-comma grid moves nothing here -- `{head} {w1 … wk}` for k from 1 to 3 over the heads `John Smith, Jr.`, `John Smith, PhD`, `, PhD`, `John Smith, MD Ma`, `John Smith, Jr. MA`, `Smith, PhD`, `, Jr.`, `John Smith, X.Y.Z.` and the words `van der de la von und St Freiherr do mc vd du Dr. Dr Prof. Prof Attorney Sir Lt.Gov. Mr. MA Ma PhD X.Y.Z. VI V Jr. Berg Jones`, 606,216 parses under the three orders, a sample reaching both comma structures): a particle past the part's first word took the words behind it, a title word among them, so `John Smith, Jr., van der Berg Prof.` read suffix 'Jr., van der Berg Prof.' where `John Smith, Jr., Prof.` and `John Smith, Jr., van Berg Prof.` (whose leading `van` chains nothing, P4) read title 'Prof.'. Now group makes no chain in such a part, and asks no chain bound there, so C2 reads every title word in it, bare (`Prof`) or mid-part (`van der Prof. Berg`) alike. DECIDED (Derek): no chain there, rather than the issue's proposal, #623's bound (`trailing_run_start`) at this site; that reads trailing titles only (H5), narrower than C2's rule, and adds a call where removing the chain takes one away. The chain's two report emitters had been handed no report list in these parts for the same reason (rules/C2's no-name-reading clause, 2026-09-28); with no chain there is nothing to suppress, and the handoff now suppresses reports only after a family comma (removing the tail half moved nothing over the suite, #624's comma-tail grid's 292,950 parses, every corpus name and case text, and the suffix-comma grid above). - MEASURED 2026-10-11 against 3b2b6f0d (#635's merge), py3.11, `nameparser.__file__` asserted on each side. #624's comma-tail grid (its recipe, decisions/P2, 2026-10-10; 97,650 texts under the three orders), with every corpus name and case text at 3b2b6f0d: 18,318 parses on 6,106 texts move a role, every one in the grid and none of 3b2b6f0d's corpus names or case texts, and no parse moves a report alone. 15,330 put a title word that stood behind a particle run into the title field (`,, 'Bob' van der Attorney` → title 'Attorney', suffix 'van der'; master suffix 'van der Attorney'), and 2,988 take a particle back out of a title the chain had joined it to, where the chain merged it with a P3-joined title and kept the tag (`,, 'Bob' van der Mr. and Mrs.` → title 'Mr. and Mrs.', suffix 'van der'; master title 'der Mr. and Mrs.', suffix 'van'). The S2 stress grid, #624's title-particle grid, #623's given-part grid and #625's particle grid: 0. The five gates exit 0, a `fix(#636)` rule in each 2.x ledger; at 1.4.0 the broad `fix(comma-family) lone post-comma piece routes to suffix/title` rule explains the new example. Frames: the reference rows and family-comma names do not move; a tail part with a particle costs less (`John Smith, Jr., Freiherr von Richthofen` 440 → 428, `John Smith, Jr., van der Berg Prof.` 462 → 452, py3.11 warm). Recorded negative control: with the chain run in these parts again, 9 tests fail -- the three `fix(#636)` rows on both surfaces, the two new rules/C2 examples, and `a_part_past_the_second_reports_no_particle_fork`, whose chain report the handoff no longer suppresses, so that row guards the change too -- and with it run only after a family comma, the review's rows `Smith, John, van der Berg Prof.` and `,, van der Berg Prof.` (both surfaces), which it added after that mutant passed the suite. - THE REVIEW (2026-10-11), measured, all garbage-neutral or better, no reading worse over the review's generated tail-part sweeps under thirteen configurations (fields, reports, `initials()`, `capitalized()` and the facade's views compared; the populations were the reviewer's and are not recorded here, so no count is quoted): a word in both the title and particle vocabularies inside a tail part now reads as a title as a plain title there already did (`John Smith, Jr., Berg St Smith` → title 'St'); a particle between two titles there becomes a suffix entry of its own (`John Smith, Jr., St von Mr. and Mrs.` → title 'St Mr. and Mrs.', suffix 'Jr., von'). Two gaps outside this change, already on master: P5's bound join still runs in a tail part after a suffix comma (`John Smith, Jr., abdul Dr. Smith` keeps 'Dr.' in the suffix where `John Smith, Jr., Berg Dr. Smith` reads title 'Dr.'), and rules/C2's no-name-reading clause is contradicted for the part after a suffix comma by #603's per-word report (`John Smith, PhD van der Berg` reports suffix-or-name on each word). - Open: #640, #641. (Both settled 2026-10-11: #640 by #637's entry, decisions/P5; #641 below.) -- 2026-10-11 (Derek), #641 — C2'S NO-NAME-READING CLAUSE MEANS A WORD READ AS A NAME, AND C1'S PER-WORD REPORT IS THE PART'S OWN. rules/C2 said a part the parse consumes wholly as suffixes "raises no report about reading a word of it as a name", whether it is the part after a suffix comma or a part beyond the second; but the part after a suffix comma reports each word its opening credential takes (`John Smith, PhD van der Berg` → suffix-or-name on 'van', 'der' and 'Berg', "follows a credential, so it reads as part of the suffix run; it may be a name word"), the report rules/S2 states ("A name word the run absorbs is reported `suffix-or-name`"), which C1's credential-opening branch reaches by reading the words after it as that run reads them, and which C1's example pins (`John Smith, PhD Jones` → `suffix-or-name`). The rule text and the code had disagreed since #603 added the report (in `assign`; #613 later moved it into `_comma.decide`), and no test held either side's wording. DECIDED (Derek): the report stays -- a word behind a credential in a part the comma made postnominal is the fork it reports in `John Smith PhD Jones` -- and C2's sentence is narrowed to what it meant: no report that a word of such a part was READ AS a name (a particle chained onto a name word, an acronym taken into the name). The part's own reports are listed by their rules: C1's report of the decision at its comma (the flip, or the first word after the comma), S2's report on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and C2's flag. Measured by the review over about 1.2 million parses of suffix-comma and tail parts: those are the only reports such a part carries. A text change only: no reading or report moves (the suite and the five gates unchanged), and the one code comment quoting the old sentence quotes the new. - + SUPERSEDES: decisions/C4's 2026-10-01 #562 bullet's mechanism and its accepted reports (its pair report and the settled-run test); decisions/C8's 2026-10-04 #603 bullet's "segment decides" wording; and decisions/C3's #575 bullet's account of WHY segment builds its unit facts from vocabulary -- `_comma._whole_name` counts over classify's tags, and `name_word_count`, which reads the vocabulary, is asked by decide and by script_segment, the agreement sweep `test_surname_unit_tags_agree_with_classify` keeping the vocabulary view equal to the tags. +- 2026-10-18 #728 — SPLIT. rules/C1 held what are now C1 and C3–C11, and this file their history; each entry moved to the file of the rule it decided, and the #429, #436/#437 and #511 entries, which decide R1's entry pass, to decisions/R1. The entries kept here moved nothing but the pointers the split broke and the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR. The #611 entry stays here, an open question on #728. diff --git a/docs/design/decisions/C10-declared-delimiter-parts-a-trailing-suffix-part.md b/docs/design/decisions/C10-declared-delimiter-parts-a-trailing-suffix-part.md new file mode 100644 index 00000000..aa06ef5d --- /dev/null +++ b/docs/design/decisions/C10-declared-delimiter-parts-a-trailing-suffix-part.md @@ -0,0 +1,9 @@ +# C10 — a declared delimiter parts a trailing suffix part + +- 2026-10-03 (Derek), #549 — A DECLARED DELIMITER PARTS A TRAILING SUFFIX PART AS A COMMA WOULD, AND THIS REVERSES #538'S SKIP READING (decisions/M2, 2026-09-26). The question #549 asked was whether a core a connective join swallows should be skipped (the text read as if the core were absent) or should end the run it stands in; Derek's ground for the second was the one a caller has when declaring one: a delimiter set to ` - ` is meant to behave like a comma around suffixes, and the simplest parser that honours that is one where it IS a comma there. So `group` cuts a tail segment at its cores before grouping anything, groups each part as the comma twin's own segment would be grouped, and drops the cores, which post_rules' entry pass still reads back as boundaries. No join, maiden walk or link search can reach across a core because none of them ever sees one, and the machinery that let them step over it is gone: the `cores` parameter of `_group_segment`, `_maiden_take`, `_run_neighbours` and `_link_joins_inside_the_clause`, `_maiden_take`'s `seen` index list, the `skip` arguments of `peel_walk` and `trailing_start`, and the #206 drop block that ran after the joins. One call per parse fewer (`tools/perf/call_count.py`, py3.11, 2026-10-03: parse 369 against 370 at 061f02da). + WHAT MOVES, against 1.4.0 and 2.3.0, measured 2026-10-03 with the released wheels (PR #600). A connective beside a core no longer joins across it: `HumanName("Smith, John, PhD - and MD", suffix_delimiter=" - ").suffix` is 'PhD, and MD', 1.4.0's answer, where 2.0.0 through 2.3.0 gave 'PhD - and MD'. `Smith, John, Puig - i Soler` and `Smith, John, PhD née Puig - i Soler` read as 2.3.0 did again ('Puig, i Soler'; maiden 'Puig', suffix 'PhD, i Soler'). A MAIDEN MARKER BESIDE A CORE TAKES NO CLAUSE, the larger cost by count: `Smith, John, MD - née Jones Smith` reads suffix 'MD, née Jones Smith' and `Smith, John, PhD née - Jones` 'PhD née, Jones', where 2.0.0 through 2.3.0 read maiden 'Jones Smith' and 'Jones'; rules/C10 records it as Accepted. At the default policy nothing moves. + THE DECLINED READING, TAKEN. #538 declined the boundary reading because it moves `Smith, John, PhD née Puig - i Soler` from maiden 'Puig i Soler' to maiden 'Puig' with suffix 'PhD, i Soler', "a birth-name link pushed into the credentials". That is what the same text with a comma typed in the core's place reads, and it is what 2.3.0 read. The marker beside a core (WHAT MOVES, above) is the same trade at a higher price, a maiden name lost rather than a link moved, and it is taken on the same ground; the review of this change found it, the first draft of this entry having weighed only the link shape. The comma reading is accepted as the cost of one rule for a declared separator, where the skip reading kept two (a lone core dropped like a comma, a core beside a connective stepped over like a word) and #549 was the seam between them. + INVARIANT, pinned by `tests/v2/test_properties.py::test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin`: every ROLE field of a name with a ` - ` core in a part after the suffix comma equals that of the same text with a comma in its place, over 129 generated texts; 102 disagreed at 061f02da, 0 after. It replaces #538's `test_a_delimiter_core_reads_as_if_it_were_not_written`, whose invariant this decision reverses. Scoped to TRAILING parts on purpose: before the first comma, or in the given part of the listing form, the core is a word (rules/C10's Accepted entry, v1 parity), and a comma typed there changes the name's structure rather than parting a suffix run, so the twin is a different parse. The ambiguity reports are not part of it: C2 reports `comma-structure` once per extra comma part, so a typed comma can add a report the core does not (`Smith, John, Puig - Puig` against `Smith, John, Puig, Puig`). + MEASURED 2026-10-03 against 061f02da (PR #600), over 19,972 texts generated after four heads plus the 1,505 corpus names: 2,624 parses move under ` - ` (2,384 in the suffix alone, 240 in suffix and maiden, every one of the 240 a maiden name lost to a marker beside a core), the same 2,624 under ` / ` and 2,528 under ` and `, with no other field and no report moving; one corpus name moves under each of two policies; and the comma twin disagreed on 752 of 2,100 texts for each of the `Smith, John,` and `Smith, John, Jr.,` heads before and on none after. + SUPERSEDES, under M2: the 2026-09-26 #538 entry (its stepping, its invariant and its Not-repaired list, which this closes); the 2026-09-22 #397 follow-up's bound argument (a core can no longer stand between a marker and the clause's first word, the segment being cut at it first); and the #418 reorder entry's closing repair, which screened a tail's cores out of the marker pass so that `Smith, John, PhD née - Jones` read maiden 'Jones' -- it reads suffix 'PhD née, Jones' now, as above. Entries there that describe the walk stepping over a tail's cores (the #424 and #533 bullets) describe machinery this change deletes; they are dated and left as written. Not changed: a core OUTSIDE a tail segment is still a word (rules/C10's Accepted entry), and a core inside a token (`RN/CRNA` under `/`) still reads by `splits_into_suffixes` and keeps the token whole. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C11-what-the-comma-decision-reports.md b/docs/design/decisions/C11-what-the-comma-decision-reports.md new file mode 100644 index 00000000..0d7f2054 --- /dev/null +++ b/docs/design/decisions/C11-what-the-comma-decision-reports.md @@ -0,0 +1,6 @@ +# C11 — what the comma decision reports + +- 2026-09-28 (Derek), #544 — A PART READ WHOLLY AS SUFFIXES REPORTS NO NAME READING OF ITS WORDS. group's particle chain runs over every comma segment, and its two emitters — `particle-or-given` when a particle behind a word of both the title and the particle vocabulary chains (since 2.0.0, de264af1) and `suffix-or-name` when the chain takes an ambiguous acronym into the name (#289/#516, 59d8f38a, in no release) — reported inside a TAIL segment, which assign reads wholly as suffixes. Each such report named a reading the parse never made, against rules/A1's "A report names the reading the parse took". A family comma's tail was already silent, since group hands the chain no report list anywhere after a family comma; the suffix comma's tails were not. Measured on the released wheels: `John Smith, Jr., Freiherr von Richthofen` reports `particle-or-given` on 'von', a token in the suffix role, at 2.0.0, 2.1.0, 2.2.0 and 2.3.0; `John Smith, Jr., PhD van Ma` and `John Smith, Jr., PhD Do Ma` report it on 'van' and 'Do' at 2.0.0 and 2.1.0 only. The `suffix-or-name` half reached `John Smith, Jr., PhD Do Ma`, `John Smith, MA, PhD Do Ma` and `John Smith, Jr., PhD van Ma` on master (e10e83b4), and the run rule of decisions/C4's 2026-09-27 #544 entry made it reachable behind ONE comma: `John Smith, PhD Do Ma` carried C1's flip and a second report on 'Ma'. FIXED by scope, not by a new test at the emitter: group passes the chain no report list in a tail segment either, so both emitters go quiet there together, and rules/C2 states the boundary for any part consumed wholly as suffixes. (Mechanism SUPERSEDED by #636, decisions/C7, 2026-10-11: group makes no chain in a tail segment at all, and the part after a suffix comma had reached none since #613, so the report list is withheld only after a family comma.) The maiden channel is a separate parameter and is untouched: a tail segment's reader is NONE, so the maiden walk reports nothing there to begin with. No mechanisms.md entry: this is AMBIGUITY-AT-THE-DECISION-SITE's own contract (a report fires only where the parse chose between live readings) applied to a stage whose reading a later stage overrides for the whole segment. MEASURED 2026-09-28, the tree against the same tree with `None if family_comma else ambiguities` restored in `group()` (the comparator), each parse recorded as its seven fields plus `(kind, [(token text, token role)])` per report, under all three name orders: 0 of the 1441 differential-corpus names move, so the gate has nothing to classify; over a comma grid — the prefixes `John Smith, `, `John Smith, Jr., ` and `Smith, John, ` times every run of one to three words drawn with repetition from {PhD, MA, Ma, Do, van, de, Jr, MEng, Ed, y, i}, each text as written, lowercased and uppercased, deduplicated to 11,049 texts — 360 parses (120 texts, every one of them mixed case) lose one `suffix-or-name` report apiece, every removed report on a token in the suffix role, 0 reports added, 0 field moves. 21 of the 120 texts carry one comma and are the run rule's reach; the other 99 carry two and moved the same way (297 parses) when the same one-line change was applied to master e10e83b4; the `Smith, John, ` prefix moves nothing, being a family comma. The grid holds no word of both the title and the particle vocabulary, so the `particle-or-given` half is witnessed by the case row alone. Pinned by the case rows `a_credential_run_after_the_comma_reports_no_chain_fork` and `a_part_past_the_second_reports_no_particle_fork`; the rows the chain still reports on outside a tail are `the_chain_reports_the_acronym_it_takes` and `titled_particle_chain_survives_a_title_that_is_also_a_particle`. +- 2026-10-01 (Derek) — A CAPITALS-SETTLED RUN OPENED BY A WORD OF THE CLASS REPORTS, AND RULES.MD#C1 NOW SAYS SO. C1 said a run whose every class word is written in capitals "reads whole in silence". That held only behind another credential: a class word OPENING the part is the first word after the comma, whose decision C1 reports either way, so `Doe, MA PhD`, `John Smith, MA MA` and `Smith, MA PhD` read wholly as suffixes and report `suffix-or-name` once, on that word, while `John Smith, PhD MA` and `Smith, PhD MA` are silent. The behavior predates #562 and is kept: nobody repeats `MA` at the end of their name on purpose, so the report is the right signal. Statement corrected, no parse moved. MEASURED 2026-10-01 on master b39c370c, the prefixes `John Smith, ` and `Smith, ` times every run of two or three words drawn with repetition from {MA, BA, ED, DO, JD, PhD, MD, Jr, Esq.} holding at least one class word: all 900 runs opening with a class word report, 895 of them read whole (14 of those hold a #562 particle pair, decisions/C4, whose count flip reports over the whole part rather than on the first word) and the other five being `Smith, MA DO DO` and its like, the one-name-word chain #562 left open; of the 560 opening with another credential, 554 read whole in silence, and the other six hold a #562 particle pair — four reading whole and reporting (`John Smith, PhD DO DO`), two being `Smith, PhD DO DO` and `Smith, Jr DO DO`. Recompute: parse that grid and bucket by whether the first word is a class word, whether `ambiguities` is empty, and whether the suffix is the whole part. The same day (Derek) rules/S2's first-slot precedence sentence gained the pointer to that exception, so the next reader does not rediscover it: S2 says the count decides the first slot after a family comma before case, true for one word (`John Smith, MA` flips by the count) and false for a capitals-settled run, where C1's shortcut reads the capitals first and the comma keeps its family reading. And S2's list of reporting slots lost "the segments beyond it": no `suffix-or-name` report lands past the second comma (`Smith, John, MA` and `John Smith, Jr., MA` are silent). It once did: at #530's merge cc78c960 `John Smith, Jr., PhD Do Ma` reported `suffix-or-name` on 'Ma' beside its `comma-structure` flag, from group's chain emitter, which the 2026-09-28 #544 bullet (decisions/C11) silenced in a tail. S2 now points at C2 for what such a part does report, which is not only `comma-structure` — an unclosed delimiter there adds `unbalanced-delimiter` (`John Smith, Jr., (Bob`), and a maiden clause standing in it is read as one (`Smith, John, Jr nee Jones MA`, maiden 'Jones MA'). Derek, 2026-10-01: the silence is right. +- 2026-10-11 (Derek), #641 — C2'S NO-NAME-READING CLAUSE MEANS A WORD READ AS A NAME, AND C1'S PER-WORD REPORT IS THE PART'S OWN. rules/C2 said a part the parse consumes wholly as suffixes "raises no report about reading a word of it as a name", whether it is the part after a suffix comma or a part beyond the second; but the part after a suffix comma reports each word its opening credential takes (`John Smith, PhD van der Berg` → suffix-or-name on 'van', 'der' and 'Berg', "follows a credential, so it reads as part of the suffix run; it may be a name word"), the report rules/S2 states ("A name word the run absorbs is reported `suffix-or-name`"), which C1's credential-opening branch reaches by reading the words after it as that run reads them, and which C1's example pins (`John Smith, PhD Jones` → `suffix-or-name`). The rule text and the code had disagreed since #603 added the report (in `assign`; #613 later moved it into `_comma.decide`), and no test held either side's wording. DECIDED (Derek): the report stays -- a word behind a credential in a part the comma made postnominal is the fork it reports in `John Smith PhD Jones` -- and C2's sentence is narrowed to what it meant: no report that a word of such a part was READ AS a name (a particle chained onto a name word, an acronym taken into the name). The part's own reports are listed by their rules: C1's report of the decision at its comma (the flip, or the first word after the comma), S2's report on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and C2's flag. Measured by the review over about 1.2 million parses of suffix-comma and tail parts: those are the only reports such a part carries. A text change only: no reading or report moves (the suite and the five gates unchanged), and the one code comment quoting the old sentence quotes the new. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C3-counting-the-words-before-the-comma.md b/docs/design/decisions/C3-counting-the-words-before-the-comma.md new file mode 100644 index 00000000..50792ba8 --- /dev/null +++ b/docs/design/decisions/C3-counting-the-words-before-the-comma.md @@ -0,0 +1,13 @@ +# C3 — counting the words before the comma + +- 2026-10-01 (Derek), #575 (where the count runs superseded by #613, decisions/C1) — THE COUNT BEFORE THE COMMA COUNTS A PARTICLE SURNAME ONCE. Every count rules/C1 makes of the part before the comma — v1's "more than one word" for an unambiguous credential, the ambiguous class's name-word count (#289, #544), and assign's two-name-word test for the positional read after a comma followed by no name word (#296/#325) — counted tokens or pieces, so `De La Cruz` was three words and `van der Berg` two (the ambiguous `van` stands as its own piece, P1's fork). This cycle's count had therefore read `De La Cruz, Ed` as a credential comma with no given name and split `van der Berg, MA` into given 'van', family 'der Berg' — an unreleased regression against 2.3.0, which read both as the listing form — and 1.4.0 through 2.3.0 all split `van der Berg, PhD`. DECIDED: a particle run and the one name word it attaches to are one word in all three counts, so a particle surname reads as a one-word surname does. + THE COMMA SETTLES P1's FORK, and that is the decision rather than a side effect. Standing alone, `Van Buren` is given 'Van', family 'Buren' with a `particle-or-given` report, so counting it as one surname makes the comma's reading disagree with the standalone one. Derek's call, after weighing the narrow alternative (count only a part P1 already reads as all surname, i.e. one led by a never-given particle like `de`): the listing form puts a surname before the comma, so the comma is evidence the standalone parse does not have, and the readings it gives are the ones a person takes — `Van Buren, Ed` given 'Ed', family 'Van Buren'; `van der Berg, MA` family 'van der Berg', suffix 'MA' (one name word, so C1 reads the case, and capitals in a mixed-case name make the credential). `Van Johnson, Dr.` moves with it, family 'Van Johnson', title 'Dr.', where 2.2.0 and 2.3.0 read given 'Van' and `tests/v2/pipeline/test_assign.py` had pinned the positional fork; that test now uses `Van Johnson Smith, Dr.`, which still has two name words. + THE PARTICLE REACHES ONE WORD, as P1's fold does, not to the end of the part as P2's chain does: `de Mesnil Jean, Dr.` under a family-first order is family 'de Mesnil', given 'Jean', two name words. The cost, and it is real for an ambiguous particle: a `van`-led part with a word after the surname counts two and keeps the positional read with P1's fork, so `van Buren John, Ed` reads given 'van', middle 'Buren', family 'John' and `van der Berg Smith, PhD` given 'van', family 'der Berg Smith', both unchanged from master. Under the default order a never-given particle loses nothing (P1's fold makes `de Mesnil Jean` all surname anyway). `_vocab.unit_ends` carries both reaches behind a `chain` flag, post_rules' fold reading the full chain as before. + ONLY PARTICLE CHAINS, not mechanisms.md#UNIT-PARTITION's full set — stated there as the Contract's one exception, and in P3's statement, whose "ONE name word wherever another rule counts them" now names C1's count as the exception. A bound given-name pair builds a GIVEN name, and P5 gives up a family word where the name has no other (`abdul Salam` alone is given 'abdul', family 'Salam'), so `abdul Salam, Ed` keeps the credential reading. Whether P3 joins a single-letter connective depends on the words of the whole name — `Carod i Rovira, Josep` joins as four words where `Ortega y Gasset` alone, three, does not — and the count is part of deciding what the whole name is, so segment could reach P3's answer only by copying P3; `Ortega y Gasset, Ed` keeps the credential reading. Approved as "particle chains and connective joins", narrowed to particles when P3's exception surfaced in implementation (the first draft joined `John e Smith, III` into one family name, caught by `tests/test_conjunctions.py`). + A WORD THAT IS ALSO A TITLE IS NO PARTICLE WHERE IT OPENS THE PART. `TITLES ∩ particles` is `{freiherr, st}` and `bound_given_names ∩ particles` is `{abu, أبو, ابو}` (recompute: `L = Parser().lexicon; L.particles & L.titles`, `L.particles & L.bound_given_names`). The first draft counted every particle, and the docs review found it reading `Freiherr von Berg, PhD` as family 'Freiherr von Berg' and `St John, PhD` as family 'St John', where every release reads the title. The first fix then excluded both overlaps in EVERY position, and the review of that fix found two errors in it. Position: inside a surname a title-particle chains as P1 and P2 chain it, and the blanket exclusion brought #575's own defect back for `de St Pierre, Ed`, `De St. Croix, Ed` and `de Abu Bakar, Ed` (family only, no given name, where 2.3.0 read given 'Ed'). And direction: 2.0 through 2.3 read `Abu Bakar, Ed` as given 'Ed', family 'Abu Bakar', so excluding the bound-given particle gave released behavior up rather than keeping it — and Abu Bakar is a common Malay surname. DECIDED: a title-particle is no particle only where it LEADS the part, and a bound given-name particle is a particle. So `Freiherr von Berg, PhD` and `St John, PhD` keep the title, `de St Pierre, Ed` reads given 'Ed', and `Abu Bakar, Ed` reads given 'Ed', family 'Abu Bakar' while `Abu Bakar, PhD` reads family 'Abu Bakar', suffix 'PhD' where 1.4.0 through 2.3.0 read given 'Abu'. The agreement test compares each token in both positions; it cannot see which position a caller passes, so case rows pin each count's flag: `de St Pierre, Ed` segment's, and `St John, Dr.` and `von St Johann, PhD` assign's, one per direction (the review of this round found assign's flag fixed to either constant passing every test, and both mutants now fail). `Freiherr von Berg, Ed` reads family 'Freiherr von Berg', given 'Ed' — 2.0.0's through 2.3.0's reading too, so no change against a release; only this cycle's count had read title 'Freiherr', family 'von Berg', suffix 'Ed'. The surname counts once and the title is no name word, so the count reads the listing form, which keeps a leading title in the family as it already did for `Prof. Cruz, Ed` (superseded for listed titles by #H6, 2026-10-17: the count is unchanged, and the listed title then peels). For `Prof.` that is a cost (rules/C3's Accepted line); for a German rank it is the right reading (Derek, 2026-10-01): since 1919 a former noble title is part of the legal surname, written between the given name and the particle (Karl-Theodor Freiherr von und zu Guttenberg), so `Freiherr von Berg` is the family name. That is also why `freiherr` is in PARTICLES at all — added with the German prefixes in 3e14ea20 (#18) without a stated reason, and the reason is this one. `Freiherr von Berg, PhD` keeps title 'Freiherr', the unambiguous credential's count taking the title as a word; both are readings of one name, and neither is changed here. A title in front is otherwise outside the fix: v1's count for an unambiguous credential counts the title as a word, so `Dr. van der Berg, PhD` reads as `Dr. van der Berg` does alone, given 'van'. + `De La Cruz, M.J. K.L.` reads given 'M.J.', suffix 'K.L.', reporting twice, as `Cruz, M.J. K.L.` does (Derek, 2026-10-01): with one name word before the comma the paired-initials count no longer reaches it, and C1's two-dotted-groups sentence now says "behind two or more name words". #563 had read it as a flipped credential run with no given name. + SEGMENT RAN BEFORE CLASSIFY, so its count built its unit facts from the vocabulary while assign derived them from classify's tags. Superseded by #613 (decisions/C1): `_comma._whole_name` counts over classify's tags, and `test_classify.test_surname_unit_tags_agree_with_classify` keeps the vocabulary view equal to the tags (its negative control is `_SURNAME_UNIT_CONTROL`). + BLAST RADIUS, measured 2026-10-01 against 65afcdb3 (PR #576) over every corpus name under the three orders (fields, ambiguity kinds, `initials()`): the gate exits 0 at all five baselines and one of the 1459 names moves, `De La Cruz, M.J. K.L.` (decided above). Against 2.3.0 the move is a class rather than a list: a leading ambiguous particle and one word, before a comma followed by an unambiguous credential or a title alone, reads as one surname (`Abu Bakar, PhD`, `van Gogh, Jr.`, `Van Johnson, Dr.`). + COST, `sys.setprofile` call counts against 65afcdb3 (PR #576): `John Smith, PhD` 209 → 217, the unambiguous credential path building its token list, and `Smith, John`, `John Smith, MA` and the benchmark reference unchanged. +- 2026-10-06 (Derek), #613 — A CONNECTIVE SURNAME COUNTS ITS WORDS BEFORE THE COMMA, and a part before the comma that is one name word with a suffix in it is the listing form's family: decisions (1) and (4) of decisions/C1's #613 entry, recorded there with the decision they were taken with. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md b/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md new file mode 100644 index 00000000..601d663a --- /dev/null +++ b/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md @@ -0,0 +1,9 @@ +# C4 — the ambiguous credential class at the comma + +- 2026-09-27 (Derek), #544 — THE NAME-WORD COUNT READS A RUN. The single-token rule rules/C1 then stated for the ambiguous class generalizes to a post-comma part of two or more words, every one suffix vocabulary or a class member (listed or by shape), at least one a member and none a single-letter roman numeral: behind two or more name words the part is the credential run, and the flip reports once over the whole part (`John Smith, Ed Ma`, `Jane Doe, MS LAc`). A title/suffix dual opening the part counts as suffix vocabulary there, and a run whose every member is listed and leans credential is left to the family-comma path, which already reads it whole. The forks and their measurements are decisions/S7's #544 entry. +- 2026-10-01 (Derek), #562 — SUPERSEDED by #613 (decisions/C1, 2026-10-06), whose decision (2) binds the part before any chain runs and silences the pair report. Decided here then: a part holding two particles side by side, which group's chain had unsettled from the capitals' reading (`John Smith, PhD DO DO` read given 'PhD', family 'DO DO John Smith'), is read by the count, two name words before the comma flipping it to the credential run, reported; the reports that flip made on shapes the family-comma path had read whole were accepted (Derek: none of them is a name anyone would write on purpose). 1.4.0 read every one of these names as the fix did. Left open then, as #562 asked: `Smith, PhD DO DO`, one name word before the comma, and `John Smith, PhD van der`. +- 2026-10-06 (Derek), #613 — NOTHING AFTER THE DECISION UNSETTLES WHAT IT BOUND, so the #562 pair report above goes silent: decision (2) of decisions/C1's #613 entry. +- 2026-10-01 (Derek), #554 — A WORD OF BOTH THE PARTICLE AND THE SUFFIX VOCABULARY COUNTS IN THE RUN, AND STILL SPEAKS FOR NOTHING. After #544 the run test (`_segment.py`) counts `vd` and `mc` as suffix words, while S2's company (`_pieces._anchors`) refuses to let them speak for the word behind them, an exclusion added within PR #552 after that PR's own anchor pass had brought back a split of `Smith vd Ma, John` into family 'Smith Ma', suffix 'vd'. That split is not only an intermediate state of #552: 2.2.0 and 2.3.0 shipped it (2.0.0 and 2.1.0 read family 'Smith vd Ma'), #530 removed it in this cycle, and #552 kept it removed. The issue framed that as two answers to one question and offered (b): end the run at such a particle, so that one "has a live name reading" predicate decides both. DECIDED (a), the shipped behavior, and no parse moves. They are two questions. Run membership asks what the word IS, and `vd` is unambiguous suffix vocabulary; company asks whether the word SPEAKS FOR the one behind it, and a particle cannot, P2 joining it forward to exactly that word. Both answers follow from the word's vocabulary, and they differ by design rather than by drift; S2's "end the run" is the company's run, not the part C1 counts. The clause reaches exactly `vd` and `mc`: `do`, also in both vocabularies, is in the ambiguous half and so is already a word of C1's class, reporting where these two do not (`John Smith, Jr do` against `John Smith, Jr vd`). Recompute the set: `L = Parser().lexicon; L.particles & (L.suffix_acronyms | L.suffix_words) - L.suffix_acronyms_ambiguous`, and drop the subtraction to see `do` with them. + The evidence is the writing that carries no other signal. Neither reading of `vd Ma` after a comma is realistic, a particle in front of a Chinese family name being as rare as `vd` or `mc` as a credential, so the mixed-case writing is an arbitrary edge case and decides nothing. Written in one case, where S2 has no capitals to read and the vocabulary is all there is, the part reads as the credential run, and that is the reading a reader would take: measured 2026-10-01 on master (fc682e36), `JOHN SMITH, VD MA` and `john smith, vd ma` read given 'JOHN'/'john', family 'SMITH'/'smith', suffix 'VD MA'/'vd ma' and report `suffix-or-name`, as `John Smith, vd Ma` does, and `John Smith, mc Ma` reads the same in every casing. It is also 1.4.0's reading, verified against the released wheel the same day: 1.4.0 read suffix 'vd Ma' and 'VD MA', and 2.0.0 through 2.3.0 read the whole name as the family. (b) would add a particle exception to C1, and to #563's paired-initials test besides, in order to give `JOHN SMITH, VD MA` family 'JOHN SMITH VD MA' with no given name: the worse reading, on exactly the input where nothing else speaks. + #563's site needs nothing of its own. `García Márquez, vd G.J.` reads as `García Márquez, PhD G.J.` does, in every casing: given 'García', family 'Márquez', suffix 'vd G.J.', and neither reports. If a surname split by a credential run in silence is a defect, it is the run's, not the particle's. Open: #573 — uniform-case `vd`/`mc` before a comma and after a family comma (decided 2026-10-02, decisions/P6's entry), and the silent mixed-case `Doe, Jane PhD vd Ma` (now #578). Recompute: `parse(s)` with `as_dict()` and `[a.kind.value for a in parse(s).ambiguities]` over each string above and its `.upper()` and `.lower()`. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C5-paired-initials-after-the-comma.md b/docs/design/decisions/C5-paired-initials-after-the-comma.md new file mode 100644 index 00000000..90d90ec6 --- /dev/null +++ b/docs/design/decisions/C5-paired-initials-after-the-comma.md @@ -0,0 +1,3 @@ +# C5 — paired initials after the comma + +- 2026-09-30 (Derek), #563 — PAIRED INITIALS ARE THE EXCEPTION TO THE NAME-WORD COUNT. The decision is recorded under decisions/S9 (#563), with the dotted shape it belongs to; decisions/C3's #575 entry records how `De La Cruz, M.J. K.L.` reads behind a one-word particle surname. This bullet is C5's pointer to both. diff --git a/docs/design/decisions/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md b/docs/design/decisions/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md new file mode 100644 index 00000000..d26efa7b --- /dev/null +++ b/docs/design/decisions/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md @@ -0,0 +1,4 @@ +# C6 — an unlisted all-caps word needs the name's case contrast + +- 2026-10-01 (Derek), #564 — AN UNLISTED ALL-CAPS WORD IS A CREDENTIAL IN THE PART AFTER A COMMA BEHIND TWO NAME WORDS, BY DEFAULT. The decision, its two-letter rule and the mixed-run membership are recorded under decisions/S9 (#564), where the caps shape's switch has always been decided; this bullet is C6's pointer to it. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C7-postnominal-part-is-read-before-any-join.md b/docs/design/decisions/C7-postnominal-part-is-read-before-any-join.md new file mode 100644 index 00000000..aebb5585 --- /dev/null +++ b/docs/design/decisions/C7-postnominal-part-is-read-before-any-join.md @@ -0,0 +1,9 @@ +# C7 — a postnominal part is read before any join + +- 2026-10-06 (Derek), #613 — NO PARTICLE RUN, CONNECTIVE JOIN OR BOUND GIVEN NAME REACHES INTO A PART READ AS POSTNOMINAL: decision (3) of decisions/C1's #613 entry, which binds the part before any join. +- 2026-10-06 #617 — CROSS-REFERENCE: the title join decision (3) of decisions/C1's #613 entry makes inside a part read as postnominal is no longer `_comma._title_chains` but group's own loop, `_pieces.join_connectives`, and it now merges a connective run first (`Smith, Chancellor of the Exchequer MP` reads suffix 'MP') and freezes the generational class; decided and measured in decisions/P3's 2026-10-06 #617 entry, which amends (3). +- 2026-10-11 (Derek), #636 — NO PARTICLE CHAIN IS MADE IN A PART THAT HOLDS NO NAME WORD. A part past the second comma, after either comma, is read wholly as suffixes, its title words as titles (C2, #603), but group still ran the particle chain over it, which builds a name unit no reading uses (the part after a suffix comma, which C2 covers too, had reached no chain since #613, `_comma.decide` binding it and emptying its segment before any join; a suffix-comma grid moves nothing here -- `{head} {w1 … wk}` for k from 1 to 3 over the heads `John Smith, Jr.`, `John Smith, PhD`, `, PhD`, `John Smith, MD Ma`, `John Smith, Jr. MA`, `Smith, PhD`, `, Jr.`, `John Smith, X.Y.Z.` and the words `van der de la von und St Freiherr do mc vd du Dr. Dr Prof. Prof Attorney Sir Lt.Gov. Mr. MA Ma PhD X.Y.Z. VI V Jr. Berg Jones`, 606,216 parses under the three orders, a sample reaching both comma structures): a particle past the part's first word took the words behind it, a title word among them, so `John Smith, Jr., van der Berg Prof.` read suffix 'Jr., van der Berg Prof.' where `John Smith, Jr., Prof.` and `John Smith, Jr., van Berg Prof.` (whose leading `van` chains nothing, P4) read title 'Prof.'. Now group makes no chain in such a part, and asks no chain bound there, so C2 reads every title word in it, bare (`Prof`) or mid-part (`van der Prof. Berg`) alike. DECIDED (Derek): no chain there, rather than the issue's proposal, #623's bound (`trailing_run_start`) at this site; that reads trailing titles only (H5), narrower than C2's rule, and adds a call where removing the chain takes one away. The chain's two report emitters had been handed no report list in these parts for the same reason (rules/C2's no-name-reading clause, 2026-09-28); with no chain there is nothing to suppress, and the handoff now suppresses reports only after a family comma (removing the tail half moved nothing over the suite, #624's comma-tail grid's 292,950 parses, every corpus name and case text, and the suffix-comma grid above). + MEASURED 2026-10-11 against 3b2b6f0d (PR #639), over #624's comma-tail grid (97,650 texts under three orders) and every corpus name and case text: 18,318 parses on 6,106 grid texts move a role and none outside the grid (15,330 put a title word that stood behind a particle run into the title field, 2,988 take a particle back out of a title the chain had joined it to), and the four other grids move nothing. Recorded negative control: with the chain run in these parts again, 9 tests fail, and with it run only after a family comma, the review's rows `Smith, John, van der Berg Prof.` and `,, van der Berg Prof.` do. + THE REVIEW (2026-10-11), measured, all garbage-neutral or better, no reading worse over the review's generated tail-part sweeps under thirteen configurations (fields, reports, `initials()`, `capitalized()` and the facade's views compared; the populations were the reviewer's and are not recorded here, so no count is quoted): a word in both the title and particle vocabularies inside a tail part now reads as a title as a plain title there already did (`John Smith, Jr., Berg St Smith` → title 'St'); a particle between two titles there becomes a suffix entry of its own (`John Smith, Jr., St von Mr. and Mrs.` → title 'St Mr. and Mrs.', suffix 'Jr., von'). Two gaps outside this change, already on master: P5's bound join still runs in a tail part after a suffix comma (`John Smith, Jr., abdul Dr. Smith` keeps 'Dr.' in the suffix where `John Smith, Jr., Berg Dr. Smith` reads title 'Dr.'), and rules/C2's no-name-reading clause is contradicted for the part after a suffix comma by #603's per-word report (`John Smith, PhD van der Berg` reports suffix-or-name on each word). + Open: #640, #641. (Both settled 2026-10-11: #640 by #637's entry, decisions/P5; #641, decisions/C11.) +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C8-listing-forms-part-after-the-comma.md b/docs/design/decisions/C8-listing-forms-part-after-the-comma.md new file mode 100644 index 00000000..9f5f4f65 --- /dev/null +++ b/docs/design/decisions/C8-listing-forms-part-after-the-comma.md @@ -0,0 +1,12 @@ +# C8 — the listing form's part after the comma + +- 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 decisions/comma-suffix-arc), 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 — rules/C10 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; rules/C8 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-10-04 (Derek), #603 (decided at group's head since #613, decisions/C1) — A CREDENTIAL OPENING THE PART AFTER THE COMMA MAKES IT THE POSTNOMINAL PART, WHATEVER FOLLOWS; AND A TITLE WORD PAST THE SECOND COMMA IS A TITLE. One word that is neither suffix nor title vocabulary used to flip the whole comma reading: `John Smith, PhD Jones` read family 'John Smith', given 'PhD', where `John Smith, MD FACS` and `Eric H. Holder, Jr. Attorney General` were already read as a name and its postnominals. Now the first suffix word of the part, with only titles in front of it, opens the part when it starts S2's run (`_pieces.starts_a_credential_run`, #602's own predicate, so the two runs cannot disagree about what starts one), and every word after it reads as that run reads: a title word as a title, any other as a suffix. The mechanism was the one #296 built for a part holding no name word, extended rather than added beside; where this entry had segment deciding it, #613 (decisions/C1) decides it once at group's head. Every word the opened part takes that would otherwise have made it the given part is reported (`suffix-or-name`), as #602's run reports what it absorbs. + THE ONE-WORD PATH, DECIDED (Derek): the whole part is suffixes. `Doe, PhD Jones` reads family 'Doe', suffix 'PhD Jones', no given name, as `Smith, PhD` already reads. DECLINED: reading the credential as a suffix and the next name word as the given name (`Smith, PhD Jones` → given 'Jones'), proposed by analogy with a title at the head of the given part. Asked whether that is a real format, it is not -- nobody writes `Family, Credential Given` on purpose -- and the one realistic input it served, a misplaced generation (`Smith, Jr. John` for `Smith Jr., John`), is garbage in, garbage out. One reading for both comma paths was the deciding ground: the one-word path disagreeing with the full-name path is the shape #429, #430 and #432 each found. Accepted cost: `Smith, Jr. John` loses 'John' to the suffix, where 2.3 read given 'John' with 'Jr.' misfiled as a title. + A TITLE/SUFFIX DUAL OPENS NOTHING (Derek). A word in both vocabularies (`md`, `ms`, `sr`, the ranks `lt`, `cpl` and the rest) is the listing form's title in front of a given name, `Smith, Ms Jane`, and a credential behind it opens nothing either (`Smith, Ms PhD Jones` keeps given 'PhD', as `Smith, MD PhD Ma` keeps given 'Ma' under #544). DECLINED after building it: a dual opening the part behind two or more words before the comma. Derek first chose it for `John Smith, MD Jones`; the reviews found what the count cannot see -- a surname of two words (`García Márquez, Ms Gabriela`, `Smith Jones, Sr. Maria`), a title in front of a one-word surname (`Dr. Smith, Ms Jane`, the count taking the title as a word), and spaced initials (`García Márquez, Ms G. J.`) each lost the given name -- and with the word explained (a dual is a word in both lists, decided by position), Derek chose that duals never open. So `John Smith, MD Jones` and the `MD - PhD - FACS` delimiter rows keep their 2.3 reading. An ambiguous-class member opens nothing either (`John Smith, Ma Jones` keeps given 'Ma'), nor does a suffix word S2's run start refuses (`John Smith, vd Jones`). + STRICT MODE: the veto refuses an initial-shaped word the suffix READING, not the run. `Smith, PSM I.` under `lenient_comma_suffixes=False` reads suffix 'PSM I.' (2.3: given 'PSM', suffix 'I.'), reporting the absorbed 'I.', which matches #602's no-comma run (`John Smith PhD V.` reports; its given-part run does not, a disagreement #602 left and this does not touch). + THE SPELLING GAP THIS FOUND, FIXED HERE. `starts_a_credential_run` refused any piece longer than one token, so the split credential `Ph. D.` group merges started neither #602's run nor this one while `PhD` started both: `Smith, John Ph. D. Jones` read middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones'. The merged piece now starts the run, the "suffix" piece tag being the Ph./D. merge's alone; `trailing_candidates` had already admitted it, so its pre-check and the predicate now agree. The no-comma spelling `John Smith Ph. D. Jones` still reads family 'Jones': the trailing peel's walk never holds the merged piece, which is a separate path and is left (AGENTS.md's spelling sweep). + C2, WIDENED (Derek): a word of the title vocabulary in a part past the second comma that is not also suffix vocabulary is a title -- the part stands behind a comma, the line H5 draws for reading a title from the end of a name -- and a piece holding a suffix word stays a suffix, a dual and a connective join that took one (`PhD - and MD`) alike. `Eric H. Holder, Jr., Attorney General` reads title 'Attorney General', and a part of title and suffix words is recognized rather than flagged. The flag's test is asked word by word against the title vocabulary, a period-joined word through `period_joined_vocab` as classify tags it (`John Smith, Jr., Lt.Gov.`, which the code review found still flagged), so `Secretary of State` reads its title but keeps `comma-structure`: knowing that group's title chain takes the connective would be `segment` modelling `group`, the sign #611 is about. Every release through 2.3.0 read these as suffixes, 1.4.0 included (`Eric H. Holder, Jr., Attorney General` suffix 'Jr., Attorney General', `Smith, John, Prof.` suffix 'Prof.', measured with the released wheels 2026-10-04); a first draft of this entry said 1.4.0 read them as titles, inferring it from the 1.4.0 run reporting no unexplained diff, when the reason was the broad `fix(comma-family)` rule there claiming them, which #603's two rules now precede by declaration. Accepted consequence: a noble rank there reads as a title, `John Smith, Jr., Freiherr von Richthofen` → title 'Freiherr', suffix 'Jr., von Richthofen'. + WHAT MOVES, measured 2026-10-04 against 97d36e0d (PR #615): four of the 1,515 corpus names, every one intended (`Steven Hardman, RN - CRNA` now reads given 'Steven', suffix 'RN - CRNA', and `John, Smith, Dr.`, `Andrew Perkins, Jr., Col. (Ret)` and `1 & 2, 3 4 5, Mr.` read their tail title); and 589 of 98,630 generated texts under three policies, 581 of them a part a credential that is not also a title opens or a title past the second comma, the other eight read by hand. Frames are unchanged on the reference band and plain names, and `John Smith, PhD Jones` pays 338 → 361. +- 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md b/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md new file mode 100644 index 00000000..008ed7f6 --- /dev/null +++ b/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md @@ -0,0 +1,11 @@ +# C9 — a one-character suffix word inside a comma part + +- 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/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. 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, over the 1069 corpus names against 56fb505c (PR #443): seven carry a one-character numeral behind a word inside a comma segment, every one bare, and none moves; the only movers are the three names this change added as examples, so the case table is the whole coverage. + 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/C8's #428 review entry 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-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/H6-title-before-a-family-comma.md b/docs/design/decisions/H6-title-before-a-family-comma.md index 59b39d19..bb7a90d6 100644 --- a/docs/design/decisions/H6-title-before-a-family-comma.md +++ b/docs/design/decisions/H6-title-before-a-family-comma.md @@ -1,7 +1,7 @@ # H6 — a title before a family comma -- 2026-10-17 (Derek), #701 — A TITLE OPENING THE PART BEFORE A FAMILY COMMA IS THE TITLE. `Dr. Smith, John` read family 'Dr. Smith', and every release did the same: 1.4.0 from PyPI gives last 'Dr. Smith' and 'Prof. Brühl' (suffix 'Dr.') for `Prof. Dr. Brühl, Ada Cornelia` (measured 2026-10-17, `PYTHONSAFEPATH=1`, wheel path asserted). The reading was written down on purpose as Accepted clauses of rules/H2 and #H3 ("before a family comma the pre-comma text is wholly the family name") and assumed by C1's Freiherr clause ('Prof. Cruz, Ed'); all three are rewritten here. The listing form moves the surname to the front and leaves the title where it was written, and CLDR's index renderings write exactly that in several locales (`prof. dr. von Selimović Hodžić, Izeta Amra`, `г-н Вернер, Иван Асен`). - DECIDED (Derek, the five points of the design proposal): (1) VOCABULARY ONLY. H2's shape does not fire before a family comma, so `Xyz. Smith, John` keeps family 'Xyz. Smith' — the slot is a surname's slot too, as the trailing slot is (#H5), and every CLDR case is a listed title. (2) The run leaves the family its last piece (`Dr., John`, `Greve, Anna`). (3) A word that is a particle too stops the run, bare or period-marked, so `St Pierre, Ed` and the 1919 legal-surname reading of `Freiherr von Berg, Ed` (C1's Accepted clause) stand, and a title in front of them still peels. (4) The peeled titles join the title field in input order, and the comma decision (`_comma.decide`) is untouched — the count before the comma already treated a title as no name word. (5) ACCEPTED COST: a bare surname-like title opening a two-word surname is the title, `King Smith, John` → title 'King', as `Greve Anna` already reads (#H1). +- 2026-10-17 (Derek), #701 — A TITLE OPENING THE PART BEFORE A FAMILY COMMA IS THE TITLE. `Dr. Smith, John` read family 'Dr. Smith', and every release did the same: 1.4.0 from PyPI gives last 'Dr. Smith' and 'Prof. Brühl' (suffix 'Dr.') for `Prof. Dr. Brühl, Ada Cornelia` (measured 2026-10-17, `PYTHONSAFEPATH=1`, wheel path asserted). The reading was written down on purpose as Accepted clauses of rules/H2 and #H3 ("before a family comma the pre-comma text is wholly the family name") and assumed by C1's Freiherr clause ('Prof. Cruz, Ed', rules/C3's Accepted clause since #728); all three are rewritten here. The listing form moves the surname to the front and leaves the title where it was written, and CLDR's index renderings write exactly that in several locales (`prof. dr. von Selimović Hodžić, Izeta Amra`, `г-н Вернер, Иван Асен`). + DECIDED (Derek, the five points of the design proposal): (1) VOCABULARY ONLY. H2's shape does not fire before a family comma, so `Xyz. Smith, John` keeps family 'Xyz. Smith' — the slot is a surname's slot too, as the trailing slot is (#H5), and every CLDR case is a listed title. (2) The run leaves the family its last piece (`Dr., John`, `Greve, Anna`). (3) A word that is a particle too stops the run, bare or period-marked, so `St Pierre, Ed` and the 1919 legal-surname reading of `Freiherr von Berg, Ed` (rules/C3's Accepted clause) stand, and a title in front of them still peels. (4) The peeled titles join the title field in input order, and the comma decision (`_comma.decide`) is untouched — the count before the comma already treated a title as no name word. (5) ACCEPTED COST: a bare surname-like title opening a two-word surname is the title, `King Smith, John` → title 'King', as `Greve Anna` already reads (#H1). ADDED IN THE PROTOTYPE, not in the proposal: #H3's floor. The first version peeled `MD` from `MD PhD Jr., John`, leaving family 'PhD' — which a case row (`family_comma_segment_zero_is_not_the_run`) caught. A title addresses somebody, and a post-nominal is not somebody, so the run stops where nothing but post-nominals stands behind it: `Dr. Jr., John` keeps family 'Dr.'. The second version stopped in front of ANY piece of the suffix vocabulary, and the design-docs review found it too broad: a title/suffix dual standing SECOND in the run blocked it and then read as a suffix inside the family (`Rev. Sr. Smith, Mary` → family 'Rev. Smith', suffix 'Sr.', where the comma-less `Rev. Sr. Mary Smith` reads title 'Rev. Sr.'), and so did `Mc` and `vd`, which the family loop's own P2 clause reads as a surname's head (`Dr. Mc Donald, John`). Its check compared `T Smith, John` with `T John Smith` over every title word, bare and period-marked, alone, behind `Dr.` and ahead of it (4,788 pairs over the 798 title words before #705's merge): 36 disagreed, 12 the decided particle class and 24 the twelve duals in second position. The floor asks the H3 question instead — is everything behind this title post-nominal? — so the duals peel wherever a name word follows them, as they do in front of a name with no comma. Declined: excluding the title/suffix duals from the run (it would keep `Ms Smith, Jane` whole); peeling by H2's shape too (the trailing doctrine, #H5, says only vocabulary claims a word in a surname's slot). MEASURED 2026-10-17 against master `eb2c1fa5`, py3.11. Snapshot (`uv run python tools/differential/snapshot.py --since master`, before this change's own rows entered the corpora): 20 entries move, all one shape — a title leaves the family for the title field — and 18 of their names are held only by corpus_cldr.jsonl. Several of those are names C1 reads as a family comma though the part after it is a credential CLDR's vocabulary lacks (`hr. Hans Henrik Sven Åge Møller, MF`); the title still comes out right there, and the family stays as wrong as before. CLDR agreement: 29 fields now agree and 28 more get closer to CLDR's value, with none regressing. Merging master after #705 listed `bapak` adds one more of the same shape, `Bapak Adinata, Dwi Putro bin MP` (title 'Bapak' out of family 'bin Bapak Adinata'), two more CLDR fields agreeing and one closer. Cost (`sys.setprofile` call events, one warm-up): `Dr. Juan Q. Xavier de la Vega III` 366 → 366 (the benchmark's `_REFERENCE` row, 307, is unmoved too), `Smith, John` 161 → 161, `Smith Jones, John` 203 → 203 (an inline tag test ahead of `is_title_piece` keeps a part no title opens free of frames), `Dr. Smith, John` 200 → 201 (one `is_title_piece`; the floor's `is_suffix_piece` on 'Smith' replaces the family loop's, which no longer asks the first family piece), `Prof. Dr. Brühl, Ada` 279 → 280. The family loop does not re-ask a piece at or past the floor, so a long post-nominal run costs one frame more than on master, not one per member: `Dr. Smith Jr. PhD MD, John` 306 → 307 (310 before the /simplify round), `Dr. Smith` plus twenty `PhD`, `, John` 903 → 904 (924 before). Those two changes moved no reading on a grid of 88,740 family-comma heads (one to four words from `Dr.`, `Prof`, `St`, `Freiherr`, `Sr.`, `Ms`, `Smith`, `van`, `Vd`, `PhD`, `Jr.`, `MA`, `V`, `de`, `Rev.`, `Mc`, `née`, before `, John`) under the default and `FAMILY_FIRST` orders, comparing fields, ambiguity kinds and `initials()`. diff --git a/docs/design/decisions/M2-maiden-marker-rule.md b/docs/design/decisions/M2-maiden-marker-rule.md index 42cc12df..e9a2fdfe 100644 --- a/docs/design/decisions/M2-maiden-marker-rule.md +++ b/docs/design/decisions/M2-maiden-marker-rule.md @@ -69,7 +69,7 @@ the fullwidth-colon marker (旧姓:佐藤 arrives as one word; the head-peel q - 2026-09-22 #397 follow-up — A DELIMITER CORE PAST THE CLAUSE'S FIRST WORD PASSES FOR THE NAME WORD BESIDE A LINK, RECORDED AS A DEVIATION RATHER THAN REPAIRED. The link exception above wants "a connective standing between two name words of the clause", and a separator the caller declared through `Policy.extra_suffix_delimiters` is structure, not a name word — so a link with one beside it joins nothing and should end the clause like any other suffix word. Between the marker and the clause's first word that already holds, the bound refusing the core before either piece test is asked. PAST that first word it does not: the core is an ordinary index to the run walk, which steps over connectives and nothing else, so it stands in for the name word on the link's left and the clause runs on past a title it would otherwise stop at. MEASURED 2026-09-22 under `extra_suffix_delimiters=(" - ",)`: `Smith, John, PhD née Puig Mr. - i Soler` reads maiden 'Puig Mr. i Soler' where its separator-less twin `Smith, John, PhD née Puig Mr. i Soler` stops at 'Puig Mr.'. THE POPULATION is the branch's own sweep, recorded with the code it describes (2026-09-21, corpus ∪ cases.py ∪ the property grids ∪ a 50,925-name generated set with cores, under thirteen core-bearing policies): the predicate is asked about a core in 51,072 of 900,023 calls, the answer differs from a core-skipping reading in 8,094 parses over 1,278 texts, and 1,824 of those move `maiden` on 288 texts — none of the 288 reachable at the default policy, `extra_suffix_delimiters` being empty there. NOT REPAIRED HERE: the fix threads the core set through three call sites into the run walk and moves the parent's reading as well, which makes it its own change rather than a rider on a review round. Open: #538. PINNED TWICE MEANWHILE. rules/M2 carries the shape as a `deviates: #538` example under an `extra_suffix_delimiters-dash` annotation — the first entry `tests/v2/rules_doc.py`'s registry has had for that field, named after the Policy field and carrying the delimiter in the suffix because the field's value is a set rather than a flag. And `tests/v2/pipeline/test_group.py::test_a_core_beside_a_link_wrongly_passes_for_a_word_until_538` holds the pair at the piece level, named so nobody reads it as the contract. ONE COST OF THE DOC EXAMPLE, worth knowing before the repair lands: its string enters `corpus_rules.jsonl`, where the differential gate parses it with the DEFAULT facade — no delimiter declared, so the dash is an ordinary name word and #538's reading is off the path entirely. It moves there for the 2026-09-20 link fix instead, and is classified as that at all five baselines: added to the `fix(#397) a link inside a maiden clause stays in the birth name` alternation at the four 2.x ledgers (suffix 'PhD i Soler' → 'PhD', maiden 'Puig Mr. -' → 'Puig Mr. - i Soler', identical at each), and given its own rule at 1.4.0, where v1 had no maiden markers and read the whole suffix-comma tail as one suffix. - 2026-09-26 #538 — A DELIMITER CORE IS STEPPED OVER BY THE LINK'S NEIGHBOUR SEARCH, AS A CONNECTIVE IS (Derek, 2026-09-26). The 2026-09-22 follow-up above recorded the deviation; this resolves it. `_run_neighbours` treats a lone core like a connective, so the word on a link's side is the one past the core and a maiden clause reads exactly as the same text written without the core: `Smith, John, PhD née Puig Mr. - i Soler` under `extra_suffix_delimiters=(" - ",)` now stops at maiden 'Puig Mr.' as its separator-less twin does. INVARIANT, pinned by `tests/v2/test_properties.py::test_a_delimiter_core_reads_as_if_it_were_not_written` over 81 generated texts, maiden field: 6 disagreed at e0f1a2fa, 0 after. Declined: THE BOUNDARY READING, which the 2026-09-22 entry's wording implied ("a link with one beside it joins nothing") — a core ending the neighbour search on its side. It fixes the reported shape equally, and it moves `Smith, John, PhD née Puig - i Soler` and `… Puig i - Soler` from maiden 'Puig i Soler' to maiden 'Puig', suffix 'PhD i Soler' — a birth-name link pushed into the credentials, where the skip reading leaves both as they read. Default-unreachable either way. THE SAME STEPPING APPLIES OUTSIDE A CLAUSE: the `frozen` loop's own `_run_neighbours` call also takes `cores`, so a connective beside a core looks past it; where a credential or nothing stands beyond, it stays a lone suffix word rather than joining, and the core it stood beside -- now a lone piece with nothing joined to it -- is dropped as #206 drops any lone core, the same way it already drops one between two ordinary post-nominals: `Smith, John, PhD - i Soler` 'PhD - i Soler' -> 'PhD, i Soler', `Smith, John, PhD - i - MD` 'PhD - i - MD' -> 'PhD, i, MD', `Smith, John, - i Puig` '- i Puig' -> 'i Puig', `Smith, John, Puig i -` 'Puig i -' -> 'Puig i'. MEASURED 2026-09-26: 191 of 8,097 generated core-bearing tail texts (the three heads `Smith, John,`, `Smith, John, Jr.,` and `John Smith,`, each followed by every 2-4-word product of {PhD, MD, Puig, i, y, -, Jr., Soler, Mr.} containing a core) move `suffix` and no other field, none reachable at the default policy; the comparison is each text parsed under `extra_suffix_delimiters=(" - ",)` with `_run_neighbours` handed its cores and with it handed none. Not repaired here: a core the join MERGES into a joined piece -- interior (`… Puig Dr. i - y Soler`, still 'PhD i - y Soler') or at the edge a link joins across, a name word standing beyond it rather than a credential or nothing (`Smith, John, Puig - i Soler` 'Puig - i Soler', its separator-less twin 'Puig i Soler'; `Smith, John, Puig i - Soler` 'Puig i - Soler', measured 2026-09-26) -- is not dropped from the suffix text; the join is right, the surviving separator is the gap. Also in that family: an ORDINARY connective's join still takes a core as its neighbour, the stepping above being for generational vocabulary alone (rules/P3) -- `Smith, John, PhD - and MD` keeps suffix 'PhD - and MD' (measured 2026-09-26), pre-existing and unchanged here. Open: #549, which carries both the surviving core and this one. -- 2026-10-03 #549 — SUPERSEDED: the skip reading the #538 entry above settles is reversed, and a declared delimiter in a trailing part now parts it as a comma would, so `Smith, John, PhD née Puig - i Soler` reads maiden 'Puig' with suffix 'PhD, i Soler', the reading #538 declined and the one 2.3.0 gave, and a marker with a core straight before or after it takes no clause (`Smith, John, PhD née - Jones` reads suffix 'PhD née, Jones', reversing the #418 entry's repair). The decision, its measurement and the invariant that replaces #538's are under C1's 2026-10-03 entry. +- 2026-10-03 #549 — SUPERSEDED: the skip reading the #538 entry above settles is reversed, and a declared delimiter in a trailing part now parts it as a comma would, so `Smith, John, PhD née Puig - i Soler` reads maiden 'Puig' with suffix 'PhD, i Soler', the reading #538 declined and the one 2.3.0 gave, and a marker with a core straight before or after it takes no clause (`Smith, John, PhD née - Jones` reads suffix 'PhD née, Jones', reversing the #418 entry's repair). The decision, its measurement and the invariant that replaces #538's are under decisions/C10's 2026-10-03 entry. - 2026-09-26 #535 — THE MAIDEN WALK READS THE TRAILING TITLE CHAIN, AND EVERY STOP ASKS ONE RELEASE QUESTION OF THE SPAN IT GIVES UP (Derek, 2026-09-26). This resolves the NOT TRANSPARENT HERE clause of the 2026-09-19 #533 entry above, which recorded `Jane Doe nee Smith MA Prof.` and `Jane Doe nee Smith Prof. MA` disagreeing and deferred the pair as one decision about what "trailing" means inside a clause. BOTH HALVES WERE TAKEN TOGETHER: a trailing title ends the clause (`Jane Doe nee Smith Prof.` reads title 'Prof.', maiden 'Smith', where 2.3.0 read maiden 'Smith Prof.'), and a credential or numeral standing in front of that title gets the stop it gets with the title absent (`… Smith MA Prof.` gives suffix 'MA', `… Smith V Prof.` suffix 'V'). Only the first half would have left the pair disagreeing in the other direction; H5's transparency is a statement about the peel and the chain read together to their fixed point, so the walk now reads the end of the clause the way assign reads the end of the name — `tail_reading`, the shared predicate mechanisms.md#ONE-PREDICATE-PER-QUESTION names — and over the forms the transparency property test below exercises, the two spellings give one answer. They split where something in or ahead of the clause stands to take the title — for example a particle chain (ahead of the clause or inside it), a bound-given join, a name left with no name word, or, after a family comma, a title word in front of a class member with a title behind it, which the given part's chain stops short of exactly as it does with no marker (`Doe, Jane nee Smith Rev. MA Prof.` reads maiden 'Smith Rev.', title 'Prof.', suffix 'MA', where `… Rev. Prof. MA` reads title 'Rev. Prof.', maiden 'Smith', suffix 'MA'; bare `Doe, Jane Rev. MA Prof.` reads middle 'Rev.') — and the Accepted pairs recorded here and in rules/M2 are examples of that, not a complete list. `tests/v2/test_properties.py::test_a_trailing_title_is_transparent_to_the_maiden_clause` holds that as an invariant over the two inputs, over heads and runs where the credential is given up and nothing ahead would take the title, with its negative control at e0f1a2fa in its docstring. ACCEPTED, THE TRANSPARENCY BOUNDARY: where the clause KEEPS the credential the spellings differ, because a clause is one contiguous run and a title inside the kept text cannot leave without the words behind it — `Doe nee Smith ba Prof.` reads title 'Prof.', maiden 'Smith ba', and `Doe nee Smith Prof. ba` maiden 'Smith Prof. ba'; `abdul nee Smith MA Prof.` reads title 'Prof.', family 'abdul', maiden 'Smith MA', and `abdul nee Smith Prof. MA` given 'abdul', maiden 'Smith Prof. MA' (2.3.0 kept every word in all four). The report differs with them: `Doe, Dr. nee Smith MA` and `… Prof. MA` report `suffix-or-name` on the kept MA, `… MA Prof.` reports nothing, the kept member no longer ENDING the clause, which is what M2's report is asked of. rules/M2 carries the first pair as an Accepted example. ACCEPTED, FURTHER SPLITS, measured 2026-09-26. Where something ahead of the clause would take the title, the spellings differ even with the credential given up in both, because the first-suffix-word stop ends the clause before the title check runs and the title check alone declines: `Jane van der Berg nee Smith PhD Prof.` reads title 'Prof.', maiden 'Smith', suffix 'PhD', and `… Prof. PhD` maiden 'Smith Prof.', suffix 'PhD' — H5's accepted particle-chain boundary inherited, the bare `Jane van der Berg Smith PhD Prof.` / `… Prof. PhD` splitting the same way — and `Berg, abdul nee Smith PhD Prof.` (bound-given join) and `Doe, Dr. nee Smith PhD Prof.` (no name word left) split likewise; the parent d9d80492 and 2.3.0 read all of these as the tree does, so only the claim is new. And a released particle with a title behind it is withdrawn: `Jane Doe nee Smith DO Prof.` reads title 'Prof.', maiden 'Smith DO', where `… Prof. DO` reads title 'Prof.', maiden 'Smith', suffix 'DO' (2.3.0 maiden 'Smith DO Prof.' and 'Smith Prof. DO'); `do` likewise. A particle INSIDE the clause does it from the other side, its chain able to take the credential behind it: `Jane Doe nee Smith do MA Prof.` reads title 'Prof.', maiden 'Smith do MA', and `… do Prof. MA` title 'Prof.', maiden 'Smith do', suffix 'MA' (2.3.0 kept every word in both). rules/M2 carries a pair of each shape as Accepted examples. ACCEPTED, H5'S REACH: `Jane Doe nee Smith King.` reads title 'King.', maiden 'Smith' (2.3.0 maiden 'Smith King.'), H5's accepted `Mary Jane King.` cost now reaching the last word of a birth name as it reaches the last word of a current one; rules/M2 carries it as an Accepted example. READER SCOPE IS UNCHANGED: the chain is consulted only where a trailing rule reads the clause's words (no comma, the part before a suffix comma, the given part after a family comma); before a family comma and in a tail segment the walk reads the peel alone and the clause keeps the title (`Doe nee Smith Prof., Jane` keeps maiden 'Smith Prof.'). THE FIRST-WORD FLOOR MOVED INTO THE CHAIN. `trailing_titles` and `tail_reading` take a `floor` (1 everywhere else), and the walk passes the position just past the marker's first word, so the chain never takes that word: `Jane Doe nee King.` keeps its maiden name (TITLES holds borne surnames and the marker announced one), and `Jane Doe nee Prof. Dr.` keeps 'Prof.' and gives up 'Dr.'. The first draft applied the floor as a clamp on the title stop after the chain had run, and that is measurably wrong rather than merely inelegant: a chain allowed to take the first word has already spliced it out of the count the re-peel reads, so `Jane Doe nee King. ba` kept 'ba' in the clause where `Jane Doe nee Smith ba` gives it up. `test_a_title_first_word_counts_as_a_word` is the invariant (a title-vocabulary first word against an ordinary one, the credential behind it read alike) and its docstring records the clamp's failure count. ONE RELEASE CHECK FOR THREE STOPS. The numeral, the credential and the title stop each ask `_release_reads_off` whether the name the take would leave reads what they give up as titles or suffixes with no join below the take absorbing it — rules/M2's invariant, "a word the clause gives up reads as a post-nominal or the clause keeps it", which the title stop now answers to as well. It is asked of the whole SPAN a stop gives up, not of the stop's own word: a first draft that checked the word alone broke the invariant on 513 of a 5,198-parse sweep (measured 2026-09-26 on that draft, which is not in the tree to recompute), because a stop gives up every word behind it — `DOE NEE SMITH PROF. MA` stopped at the title and handed the MA to the family, where the name left standing reads MA as a name; it keeps maiden 'SMITH PROF. MA' now. The question is asked per reader, the way that reader reads: the TRAILING reader is assign's own `tail_reading` over the view; the GIVEN_SLOT reader has its count settled by the comma, so it is the writing alone with a name word ahead. The name-word-ahead half is what keeps `Dr. nee Jones Smith Prof.` whole — the take would leave `Dr. Prof.`, whose Prof. would be the family name. THE JOINS. A released span holding a title with a particle ahead of it declines, because P2's chain runs on over a trailing title (rules/H5's Accepted `John van der Berg Prof.`): `Jane van der Berg nee Smith Prof.` keeps maiden 'Smith Prof.', and a released particle with a title behind it is withdrawn for the same reason (`Jane Doe nee Smith MA do Prof.` keeps maiden 'Smith MA do'). The bound-given (P5) half of `_join_takes_the_member` is asked for the GIVEN_SLOT reader alone: P5 is `BoundJoin.LENIENT` only after a family comma, and before one its STRICT reserve already refuses a join that would change a suffix reading, so asking there over-declined — `abdul nee Smith V` kept 'V' in the clause, where the scoped check lets it go to suffix as 2.3.0 did. The consequence is recorded by a case row rather than prevented: `abdul nee Smith Dr.` reads title 'Dr.', family 'abdul', maiden 'Smith', which is how `abdul Dr.` reads bare (2.3.0 read given 'abdul', maiden 'Smith Dr.'). THREE NUMERAL-STOP READINGS MOVE WITH IT, decided in rather than deferred, since each is the same invariant broken at the stop this change was already rewriting. The numeral stop never asked the join question: `Berg, abdul nee Smith V` read given 'abdul V' at 2.2.0 and 2.3.0 (given 'abdul nee', suffix 'V' at 2.0.0 and 2.1.0, #411's reserve differing between those pairs before the walk runs) and reads maiden 'Smith V' now. After a family comma the given slot reads a lone numeral as a suffix only where the given part is the LAST comma part — assign's own two-segment condition from #144 — and the walk now asks it too (`tail_follows`): `Doe, Jane nee Smith V, PhD` read middle 'V' at 2.2.0, 2.3.0 and e0f1a2fa, an M2 violation predating this change that its first draft had extended to `… V Prof., PhD`, and it reads maiden 'Smith V' now, as 2.0.0 read it. The withdrawal reaches through a title behind the numeral, so `Doe, Jane nee Smith Prof. V, PhD` keeps maiden 'Smith Prof. V' where `Doe, Jane nee Smith V Prof., PhD` gives up the title — the same asymmetry the bare given slot has with no marker in it (`Doe, Jane Prof. V, PhD` against `Doe, Jane V Prof., PhD`). And the numeral stop reads FROM the marker, unlike the other two, so a numeral straight after the marker is not held to the first-word floor, and it may decline the clause; examples, not a rule over every shape: `Dr. nee V` and `Doe, J. nee V` keep maiden 'V' as 2.3.0 did, though `Doe, J. V` reads suffix 'V', and `Doe nee V, Jane` and `Smith, John, PhD nee V` read no clause at all (family 'Doe nee V', suffix 'PhD nee V', as at 2.3.0) — `Jane Doe nee V Prof.` read maiden 'V Prof.' at 2.3.0 and reads family 'nee', suffix 'V', title 'Prof.' now, as `Jane Doe nee V` reads plus the title. After a family comma with another comma part behind the given one, the same #144 condition that keeps `Doe, Jane nee Smith V, PhD` whole now keeps a lone numeral in the clause as well: `Doe, Jane nee V, PhD` read given 'Jane', middle 'nee V', suffix 'PhD' at 2.3.0 and at the parent d9d80492 and reads maiden 'V', suffix 'PhD' now, as 2.0.0 and 2.1.0 read it, `Doe, Jane nee V, Jr.` likewise — the marker had been read as a name word there at 2.2.0 and 2.3.0, and a case row records it. A LINK THE WALK STOPS AT ASKS THE RELEASE QUESTION TOO. Reading the end of the clause through the title chain makes the link exception refuse a link it used to join — in `Jane Doe nee Smith i DO Prof.` the DO is the peel's once the title is chained — and a stop at a link gives up the words behind it. Unchecked, that put 'Doe i' in the middle name and 'DO Prof.' in the family (e0f1a2fa read maiden 'Smith i DO Prof.'), and `Berg, abdul nee Smith i V Prof., MD` read middle 'V'. So a link the exception refuses asks `_release_reads_off` of the run it would give up — only where the exception, bounded by the peel over the words as WRITTEN, would have joined it (the refusal is the title chain's), where a trailing rule reads the clause, and where the link is not the first word after the marker (a first-word stop declines the clause and gives nothing up); where that fails the clause keeps the link and walks on. Scoped that narrowly because a first version asked it of every refused link and kept links the name left standing reads off: `Doe, Jane nee Smith i V` kept maiden 'Smith i' where every release gives up suffix 'i V' (23,472 of a 411,936-parse link grid moved clean readings, measured 2026-09-26). With it, the given-part model reads the lenient numeral in both of assign's passes — the literal last piece first, then the last one standing once the chain has taken the titles behind it — and only where no comma part follows (`Doe, Jane nee Smith i V Prof.` gives up 'i V' and the title, as bare `Doe, Jane i V Prof.` reads them; `… i V Prof., PhD` keeps maiden 'Smith i V'). The same model moves the credential stop where a lenient numeral follows the credential: `Doe, Jane nee Smith MA V` now gives up 'MA V' (suffix 'MA V', maiden 'Smith') as bare `Doe, Jane MA V` reads it, where d9d80492 and 2.3.0 read maiden 'Smith MA', suffix 'V'; `… MA V, PhD` keeps maiden 'Smith MA V'. Over that link grid (links i, y, e, and, i y, y i, and i × 8 heads × bodies × `, MD` tail × 3 cases × 3 name orders), against the tree before the link check: 0 new violations, 6,195 fixed, and the clean readings that move now match the bare given part's (`DOE, JANE NEE SMITH MA I` gives up 'MA I', as `DOE, JANE MA I` reads suffix 'MA I'). `Jane Doe nee Smith i DO Prof.` now reads title 'Prof.', maiden 'Smith i DO' and reports the kept DO, and `Berg, abdul nee Smith i V Prof., MD` maiden 'Smith i V Prof.', suffix 'MD'. A RELEASED TITLE THAT IS ALSO A PARTICLE IS KEPT AFTER A FAMILY COMMA, because P6 attaches a particle trailing the given part to the family: released by the title stop, 'St.' in `Doe, Jane nee Smith St.` reads family 'St. Doe', so `_join_takes_the_member` declines it and the name reads maiden 'Smith St.', as e0f1a2fa and 2.3.0 read it, and so does `Doe, Jane nee Smith MA St.`. Titles only — a credential that is also a particle ('DO') is the given slot's own lean, which the credential stop has already asked (#533). A FOURTH MEASUREMENT, against e0f1a2fa rather than d9d80492: the M2 invariant over a fuzz of twelve heads ({`Jane Doe`, `Doe, Jane`, `Doe, Prof.`, `Jane Doe, PhD`, `Doe, J.`, `Berg, Jane van der`, `Jane van der Berg`, `Berg, abdul`, `abdul Berg`, `J. Doe`, `Prof. Jane Doe`, `Dr.`}), ` nee Smith ` and every one-to-three-word sequence over {MA, Ma, V, Prof., St., King., PhD, ba, do, DO, Jr., i, van, y} holding at least one of Prof., St., King., then nothing or `, MD`, as written, upper- and lower-cased (107,352 parses, measured 2026-09-26 on the narrowed tree; the intermediate tree above read 2,228): 588 texts that violated it at e0f1a2fa no longer do, and 12 newly do, all `Dr. nee Smith i MA|V` followed by `Prof.`, `King.` or `St.`, with or without `, MD`. Each is the title-carrying twin of `Dr. nee Smith i MA` / `Dr. nee Smith i V`, which read family 'i' at e0f1a2fa already: the bare-title head leaves no name word, the unguarded stop's class (#548), which the title now reaches because it leaves the clause. ACCEPTED: `Jane Doe (nee Smith Prof.)` reads title 'Prof.', maiden 'Smith' — bracket content ending in a period is suffix-shaped (S1), so the brackets are dropped and the clause is read bare, outside the reach of the delimiter precedence; rules/M2 carries it as an Accepted example. MEASURED 2026-09-26, the tree against its parent d9d80492, over three generated grids, and these are dated snapshots. Recipe: grid A is each head in {`Doe, Jane`, `Doe, J.`, `Doe, Prof.`, `Berg, abdul`, `Doe, Jane van`, `Jane Doe`, `J. Doe`, `Dr.`, `abdul`, `Jane van der Berg`, `John`}, then ` nee `, then every sequence of one to three words (repetition allowed) from {Smith, Jones, Prof., Dr., Sir, MA, DO, PhD, V, III, i, do, van, Ma, M.A., King., Rev., ba}, then either nothing or `, PhD`, each text as written, upper-cased and lower-cased, duplicates removed — 327,936 texts; grid B, aimed at the bound-given join, is the same construction over heads {`Dr. abdul`, `abdul rahman`, `Dr. abdul rahman`, `Berg, Dr. abdul`, `Berg, abdul rahman`, `abu`, `Berg, abu`, `Mr. abu`, `Dr.`, `Berg, Dr.`, `abdul`, `Berg, abdul`}, words {Smith, Prof., MA, V, do, van, PhD, Ma, ba, III, Jr., M.A.} and tails {nothing, `, PhD`, `, Jr., MD`}, plus these fourteen texts, as written only: `Jane Doe nee Ph. D. Prof.`, `Jane Doe nee Ph. D. Smith Prof.`, `Jane Doe z domu King. ba`, `Jane Doe z domu Smith Prof.`, `Jane Doe nee King. Prof. ba`, `Jane Doe nee King. Prof.`, `Doe, Jane nee Smith V,`, `Doe, Jane nee Smith V, ` (with the trailing space), `Doe, Jane, nee Smith V`, `Doe, Jane nee Smith V Prof., PhD`, `Doe, Jane nee Smith Prof. V, PhD`, `Doe, Jane nee Smith MA, PhD`, `Doe, Jane nee Smith V (Jr.)`, `Doe, Jane nee Smith V "Bo"` — 173,057 texts. The invariant tested is M2's: in a parse with a non-empty maiden field, every token written after the ` nee ` marker is roled maiden, title or suffix (so the two `z domu` texts are parsed and not checked). Grid C, a four-word probe of the title chain after a family comma, is each head in {`Doe, Jane`, `Jane Doe`, `Doe, J.`, `John Smith`, `Doe, Jane Mary`}, then ` nee `, then every sequence of four words (repetition allowed) from {Smith, Jones, Prof., Dr., King., MA, PhD, V, Jr., ba, do}, then either nothing or `, PhD`, as written only — 146,410 texts. Per grid, the counts are: texts that newly violate the invariant at the tree, texts that violated it at the parent and no longer do, texts that violate it at both. Grid A: 0, 2,012 and 17,854; grid C: 0, 1,368 and 15,534; grid B: 0, 1,222 and 11,149 (all re-measured 2026-09-26 on the tree with the link and particle-title checks below, as narrowed; an intermediate tree whose link check fired on every refused link read grid A 0, 3,772 and 16,094, its extra 1,760 fixes being links the as-written reading refuses too, which are #548's class), one of those last being the check itself counting the quoted nickname in `Doe, Jane nee Smith V "Bo"`. In grids A and B, every other remaining violation has the clause ending immediately before a word of the unambiguous suffix vocabulary written the way the plain suffix-word stop takes it (PhD, III, M.A., Jr., and a lower-case i or v; never a bare capital, which reads as an initial), which is where that stop ends a clause without asking a release question. DEFERRED: the walk's plain suffix-word stop — the one ending the clause at the first suffix word, as distinct from the trailing numeral, credential and title stops — asks no release question at all, and where the words behind it cannot read as post-nominals they land in a name part: on degenerate heads the stop word itself does (`Dr. nee Smith PhD Prof.` reads family 'PhD', `Doe nee Smith Jr. Prof., Jane` family 'Doe Prof.'), and with an ordinary head the words behind it do (`Doe, Jane nee Smith PhD Smith` reads middle 'Smith'). Unchanged here, and what "the clause keeps it" should mean where the kept words would follow a credential the clause itself ended at is its own question; rules/M2 carries `Doe nee Smith Jr. Prof., Jane` as an Accepted example meanwhile. The trailing numeral's stop has the same gap before a family comma, where it is made over the peel alone with no release question: `Doe nee Smith V, Jane` reads family 'Doe V', maiden 'Smith' (so did 2.2.0, 2.3.0 and the parent d9d80492; 2.0.0 and 2.1.0 read maiden 'Smith V'), and rules/M2 carries it beside the other. Open: #548. COST, measured 2026-09-26 on CPython 3.11.16 as profiler call events in one `Parser.parse` after a warm-up parse, parent → tree: `John Smith` 171 unchanged, as are `Jane Doe Prof.` and `John Smith MA`; `Jane Doe nee Smith` 244 → 246; `Jane Doe nee Smith MA` 377 → 388; `Jane Doe nee Smith Prof.` 276 → 372, the one shape that now runs the chain and a release check it never ran. Recompute: count `sys.setprofile` call events around the second of two `Parser().parse` calls, with the tree and d9d80492 each first on `sys.path`. - 2026-09-27 (Derek), #544 — S2'S COMPANY CLAUSE DOES NOT REACH ACROSS A CLAUSE, recorded as an Accepted boundary rather than repaired. `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' and reports it, the clause's own words standing between 'Jr.' and 'Ma', where the clause-less `Jane Doe Jr. Ma` reads suffix 'Jr. Ma'. tests/v2/test_properties.py's clause-agreement walk pins the pairs that differ for exactly this reason as its `anchored_head` class — 810 of its 20,412 pairs, recorded 2026-09-27, 0 with the anchor off. Inside the clause the company reads as it does anywhere: `Jane Doe nee Smith PhD MEng` ends the clause at 'PhD' and reads suffix 'PhD MEng', maiden 'Smith'. diff --git a/docs/design/decisions/P3-connectives.md b/docs/design/decisions/P3-connectives.md index 80489e23..e20d73e3 100644 --- a/docs/design/decisions/P3-connectives.md +++ b/docs/design/decisions/P3-connectives.md @@ -53,7 +53,7 @@ - 2026-09-24 #478 — CROSS-REFERENCE, no change to this rule: case repair's hyphen clause is the one place a marked letter in a name written wholly in one case is read as the connective rather than an initial — `maria silva-e-sousa` repairs to `Maria Silva-e-Sousa` beside the spaced `Maria Silva E Sousa`, and `conjunctions_ambiguous` does not reach the hyphenated form. Decided there, with its cost (`J-E-P DUPONT` → `J-e-P Dupont`): decisions/R4's 2026-09-24 #478 bullet. - 2026-09-26 #538 — CROSS-REFERENCE: rules/P3's separator sentence (a declared delimiter is read past as a connective is) is decided and measured in the 2026-09-26 #538 entry under `### M2`. -- 2026-10-03 #549 — CROSS-REFERENCE: that separator sentence is reversed; it now says a declared separator ends the search as a comma would, a connective never joining across one, and the decision is C1's 2026-10-03 entry. +- 2026-10-03 #549 — CROSS-REFERENCE: that separator sentence is reversed; it now says a declared separator ends the search as a comma would, a connective never joining across one, and the decision is decisions/C10's 2026-10-03 entry. - 2026-10-06 #617 — the comma decision joins title words with GROUP'S connective loop, not a copy of it. `_comma.decide` reads the part after the first comma before group joins anything (#613), and the one join the part can hold — a connective binding title words into one title, `John Smith, Mr. and Mrs.` — was made by `_comma._title_chains`, a second copy of the P3 loop in `_group_segment`. The two had already parted three ways: the copy merged no connective run before joining, skipped a single-letter connective unconditionally where group skips it only under the rootname count's carve-out, and derived the title kind alone. Both now call `_pieces.join_connectives` (mechanisms.md#ONE-PREDICATE-PER-QUESTION), which takes the carve-out's answer from its caller (`letter_stays`: group's `total < 4`, always true for the comma decision's own reading, and reaching a LONE single-letter connective only, one beside another connective merging into their run first), a `titles_only` mode for the comma part (a join is made only into a title and takes no prefix kind, any other connective left a word for the reading to place, which is what the copy did), and the `frozen` token set group already passed itself. The merge and the piece predicates it needs moved with it (`merge_pieces`, `joined_tags`, `is_conj_piece`, `is_prefix_piece`), and `_comma._reading_pieces` builds the reading's input, calling the shared loop only where the part holds a connective left free — nearly no part does, and the first cut, calling it always, cost every family-comma parse two frames per word of the part, one `is_conj_piece` call in each of the loop's two passes (`Smith, John` +2, `Smith, John` with sixteen more given words +34, measured inside decide in #617's review); `test_a_comma_part_with_no_free_connective_skips_the_join` holds the gate, with that control recorded. DECIDED (Derek, 2026-10-06): (1) A CONNECTIVE RUN merges in the comma part as it does in the name — P3's own "connective runs included" — so a title holding `of the` reads whole there too, and a single letter beside another connective joins as a member of their run. `John Smith, Minister of the Interior and Mrs.` reads given 'John', family 'Smith', title 'Minister of the Interior and Mrs.' (83f4e914: family 'John Smith', the copy having joined 'Minister of the' alone and left 'Interior' a name word); `Smith, Chancellor of the Exchequer MP` reads suffix 'MP' (83f4e914: given 'MP'); `John Smith, Mr. y and Mrs.` reads title 'Mr. y and Mrs.' behind given 'John' (83f4e914: family 'John Smith'). The garbage case the issue named, `Prof. Smith, and and Dr.`, reads title 'Prof. and and Dr.', family 'Smith' (83f4e914: title 'and and Dr.', family 'Prof. Smith'). Every one of these reads as 2.3.0 read it, checked against the released wheel, and so did b547c247: what moves on realistic input was #613's copy regressing names it never meant to touch, between releases, so no release note follows. A LONE single-letter connective is unchanged: the comma decision's reading joins no title across one, so a part of titles it separates is no titles-alone part and the listing form reads it, where P3's own count decides the join — `Juan Garcia Lopez, Sr. y Sra.` keeps given 'y' and title 'Sr. Sra.', while `Smith, Sr. y Sra. Juan Maria` joins title 'Sr. y Sra.' behind given 'Juan', both as 2.3.0 and 83f4e914 read them. A case row pins the first. No rules.md text states it, because it is no rule of the comma's: the first review round added a C1 clause saying a lone letter after a comma always stays a word, and the second struck it with the `Juan Maria` reading, P3 deciding in the listing part as everywhere else. (2) EVERY CONNECTIVE OF THE GENERATIONAL CLASS IS FROZEN in the part the comma decision reads, P3's "joins only where a name word stands on each side of it" applied to a part read for credentials, which holds no name word — group freezes the ones it finds without a name word beside them, the same rule. Frozen means no join or run of its own; a neighbour's join may still take one in, as in group (`Smith, Dr. and i` reads title 'Dr. and i'). The default class is `i`, which as one letter the copy never joined and which reaches the freeze only through a run: without it the shared run merge took `Rovira, Dr. i i` into title 'Dr. i i', which `test_a_link_that_joins_nothing_changes_no_field` caught. A generational word of more letters reaches it in a plain join too, which only caller vocabulary can spell, and there the copy DID join it: under `Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"})`, `Smith, Mr. og Mrs.` reads title 'Mr. Mrs.', suffix 'og' (83f4e914 and 2.3.0: title 'Mr. og Mrs.'; b547c247 as now), and as a plain connective `og` still joins. diff --git a/docs/design/decisions/P6-trailing-orphan-particle.md b/docs/design/decisions/P6-trailing-orphan-particle.md index 6cfa7c8d..cd3da0d9 100644 --- a/docs/design/decisions/P6-trailing-orphan-particle.md +++ b/docs/design/decisions/P6-trailing-orphan-particle.md @@ -1,6 +1,6 @@ # P6 — the trailing orphan particle -- 2026-08-23 — `Jong, vd` is C1's now, not P6's: C1's listing-form clause (#296's bundle) reads a post-comma part that is nothing but suffix words as the credential run, and `vd` is suffix vocabulary, so the row reads family 'Jong', suffix 'vd' by statement (it read so before, by the lone-piece deviation). The #380 scope note below calling it deliberately unresolved is superseded for that one row; the P6 question — a tussenvoegsel with no given word left — is unchanged. +- 2026-08-23 — `Jong, vd` is C1's now, not P6's: C1's listing-form clause (#296's bundle, rules/C8 since #728) reads a post-comma part that is nothing but suffix words as the credential run, and `vd` is suffix vocabulary, so the row reads family 'Jong', suffix 'vd' by statement (it read so before, by the lone-piece deviation). The #380 scope note below calling it deliberately unresolved is superseded for that one row; the P6 question — a tussenvoegsel with no given word left — is unchanged. - 2026-08-18 #379/#380 IMPLEMENTED — the rule shipped as stated, both `deviates:` markers out. Keyed on the token's VOCABULARY rather than its assigned role, which is what gives the S2 precedence its effect: assign reads a trailing `vd`/`mc`/`do` as a post-nominal, and the attachment overrides that after a comma. The words-to-spare guard is a PIECE test, not a count — every trailing piece that is wholly particles attaches, and the run must leave a GIVEN word ahead of it, so "Nguyen, Van" keeps its only given word. Not "a name word": see the defect below, where that wider reading passed on family text P1 had just produced. MIDDLE was in the test briefly and came out — 0 hits over 740,552 instrumented guard sites, and structurally unreachable, since the only rule that leaves a MIDDLE with no GIVEN ahead of it is gated on state.order, which assign never records on the family-comma path P6 runs on. Measured: ONE of 751 corpus names moves ("Vega, Juan de la"), at all three baselines, and it needed a new ledger rule in each. The LEDGER rule's regex — not the parse rule, which has no regex and fires on the whole particle vocabulary through the tag — is a PARTIAL copy of PARTICLES, deliberately: the words that actually end a Dutch, Iberian or German listing, not all 70. The ledger guards forced that narrowing: the first draft used the neighbours' bare comma regex, which claimed 215 names and reached two protected exclusions. A member `des` had to come out as well, and for a reason worth keeping straight: it is not in PARTICLES at all, so it could copy nothing, and the text it reached was the opening of "Designated Judge David A. Ezra" rather than a person's name. `_reaches_non_vocabulary("des", PARTICLES)` prints the FRAGMENT it matched, `['Des']`, not the name containing it — which is how the retracted wording arose in the first place. Two defects found in review, both from the rule's neighbours rather than from its own logic. P1's fold runs EARLIER in the same function and retags all of segment 1 to FAMILY, so a words-to-spare test written as "some name role remains" passed on family text P1 had just produced — and the attachment then hoisted the particle in front of a base it never preceded ("Smith, de Mesnil van" → 'van Smith de Mesnil'). 1.4 gives 'de Mesnil van Smith' for that input and 2.1 gives 'Smith de Mesnil van', so neither the defect nor the fix restores 1.4 — what the fix restores is 2.1, the pre-comma-family reading having moved back in 2.0. The test has to name GIVEN, which is what the rule says. And a post-nominal sits BEHIND the tussenvoegsel in this listing, so the backward walk stopped on it and the attachment never fired: "Berg, Jan van Jr." and "Berg, Jan van, Jr." parsed two ways on nothing but a comma. The walk now steps over a trailing piece holding no name, unless it is itself particle vocabulary — `vd` arrives suffix-roled and IS the run. @@ -54,7 +54,7 @@ Decided 2026-08-19 (was Open: [#380](https://github.com/derek73/python-nameparse A stale index list was repaired with it: O1 and O2 retag between roles without recomputing, and this site was the first consumer of `families` after them (33,840 stale observations instrumented). No shipped input reaches a wrong answer through it — no `_TURKIC`/`_EAST_SLAVIC`-matching word is particle vocabulary — so the fix is unwitnessed by any test and is recorded here instead. - 2026-09-18 #531 (cross-amendment) — THE PRECEDENCE OVER S2 GAINS ONE EXCEPTION, AND THE RUN WALK GAINS ONE STAND-DOWN CONDITION. An all-caps member of the AMBIGUOUS credential class, in a name written in more than one case, reads as the credential and this attachment stands down: `Doe, John DO` is suffix `DO` where `Doe, John Do` and `SMITH, JOHN DO` still attach. The argument, the accepted cost (`SMITH, JOHN DO` paired with `NASCIMENTO, EDSON ARANTES DO`) and the two carve-outs are in decisions/S5's 2026-09-18 `do` bullet; what belongs here is the CONDITION, which is narrowed to the ambiguous tag rather than to the suffix role: a suffix-roled single-token piece carrying that tag is not part of the run, while `vd` and `mc` — unambiguous suffix vocabulary, also particles, also suffix-roled — stay inside it and are unaffected. Keying on the role instead would have stood the attachment down for them too, and keying on nothing would have let it silently override the slot's reading (`Doe, John DO` read family `DO Doe` with the condition absent, verified). The 2026-08-18 entry's "assign reads a trailing `vd`/`mc`/`do` as a post-nominal, and the attachment overrides that after a comma" is unchanged for `vd` and `mc` and now has this one exception for `do`. -- 2026-10-02 (Derek), #573 — A WORD OF BOTH THE PARTICLE AND THE UNAMBIGUOUS SUFFIX VOCABULARY READS ALIKE IN EVERY CASE AT THE THREE SITES WHERE THE UNIFORM CASE SPLIT IT. Resolves the shapes the C1 section's 2026-10-01 #554 entry left open. `vd` and `mc` split in names written wholly in one case, in three shapes, each split one stage acting on a reading the next never took. The mixed-case spellings were not all right either: `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd' in 2.2.0 and 2.3.0, #530 having fixed it only in this cycle, and the fixes below move mixed-case names as well as uniform ones (blast radius, below). +- 2026-10-02 (Derek), #573 — A WORD OF BOTH THE PARTICLE AND THE UNAMBIGUOUS SUFFIX VOCABULARY READS ALIKE IN EVERY CASE AT THE THREE SITES WHERE THE UNIFORM CASE SPLIT IT. Resolves the shapes decisions/C4's 2026-10-01 #554 entry left open. `vd` and `mc` split in names written wholly in one case, in three shapes, each split one stage acting on a reading the next never took. The mixed-case spellings were not all right either: `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd' in 2.2.0 and 2.3.0, #530 having fixed it only in this cycle, and the fixes below move mixed-case names as well as uniform ones (blast radius, below). BEFORE A FAMILY COMMA (P2). `SMITH VD MA, JOHN` read family 'SMITH MA', suffix 'VD' (2.2.0, 2.3.0 and this cycle's tree; 2.0.0 and 2.1.0 read family 'SMITH VD MA'). In one case the S2 trailing reading takes `VD MA`, so the particle chain stops before `VD`; the part before a family comma then takes strict suffix words one piece at a time, and the lone `VD` was peeled from between two family words. DECIDED in that per-piece read: a particle that is suffix vocabulary too, with a name piece behind it in the part, heads that name and stays family (P2's join), so `VD MA` reads family whatever the case. Not decided in the chain: a comma followed by no name word reads that same part positionally, trailing credential included (`Berg de MA, Prof.` keeps suffix 'MA' and its report), and the chain runs before that choice is made. A first draft skipped the chain's stop for the whole family part and moved exactly that shape; a widened grid caught it. Declined: reading `VD MA` as credentials, family 'SMITH', which the comma's naming of the family argues against. `SMITH VD, JOHN` and `SMITH VD JR, JOHN` keep the suffix: nothing, or a suffix word, stands behind the particle. BETWEEN CREDENTIALS AFTER A FAMILY COMMA (P6). `DOE, JANE PHD VD MA` read family 'VD DOE', suffix 'PHD, MA': assign had read `PHD VD MA` whole, and P6's attachment, which outranks the suffix reading for such a word by design, reached into the run and pulled `VD` out. DECIDED: P6 gains a third exception — a lone such word with a post-nominal read in front of it and another behind, a title between it and the one in front transparent (H5), keeps the post-nominal reading in any case, reported as S2's credential fork. The test is the roles assign gave, asking whether it read the run whole, so a member read as the credential in front speaks as a degree does (`DOE, JANE MA VD PHD` stays whole); a first draft asked S2's company query instead, which counts only an unambiguous credential in front, and missed that shape and the titled one (`DOE, JANE PHD PROF. VD MA`). A generation counts on either side, and that is a decision rather than a side effect: P6's tussenvoegsel stands right behind the given name, so any post-nominal between them says the word is not one, and `Berg, Jan PhD vd Jr.` reads suffix 'PhD vd Jr.' where 2.2.0 and 2.3.0 read family 'vd Berg', suffix 'PhD, Jr.' — the shape `Berg, Jan van Jr.` P6's rationale names has no post-nominal in front and still attaches. No title is skipped BEHIND the word: a title there leaves it a name in assign (`DOE, JANE PHD VD PROF. MA` reads middle 'VD PROF.', as before), so a skip there was dead code, measured never to fire over the grid below, and was removed. Boundaries: with nothing behind it the word ends the name and attaches (`Doe, Jane PhD vd` → family 'vd Doe', 2.2.0's and 2.3.0's reading); two such words side by side are one particle run, which P6 takes whole as its first exception says (`DOE, JANE PHD VD MC MA` → family 'VD MC DOE', unchanged); a plain particle between credentials was never read as a post-nominal and attaches as before (`Doe, Jane PhD de PhD` → family 'de Doe'). Declined: middle 'VD MA', 2.0.0's and 2.1.0's reading — under #554 run membership asks what the word IS. ONE NEVER-GIVEN PARTICLE OPENING THE GIVEN PART (S2, P1). `SMITH, VD MA` read family 'SMITH VD', suffix 'MA'. S2's given-part slot reads a member ending the given part as the credential because "the words to spare are there by construction", the first word after the comma being the given name; a never-given particle is not one — P1's fold takes it forward into the family — so `MA` was read as a credential with no given name left, and the fold took `VD` alone. DECIDED: where the given part opens with a never-given particle and nothing but particles stands before the member, chained (`VD DE LA`) or not, the member is the word the particle attaches to and reads as a name: family 'SMITH VD MA', the reading of 2.0.0 through 2.3.0 and of `Smith, vd Ma`. The particle test is P1's own predicate (`_pieces.is_lone_never_given_particle`), so the slot cannot drift from the fold it predicts. Plain never-given particles share the shape (`smith, de ma`). Boundaries: capitals in a mixed-case name still make the member the credential, as they do with no words to spare anywhere (`Smith, de MA` keeps suffix 'MA'); an ambiguous particle can be a given name (`SMITH, VAN MA` keeps given 'VAN', suffix 'MA'); and a name word between the particle and the member is the word it attaches to, leaving the member a word to spare (`SMITH, VD BERG MA` keeps suffix 'MA'). Declined: family 'SMITH', suffix 'VD MA' (as `SMITH, PHD MA` reads) — under #554 `VD` speaks for nothing, so only the missing given name could have made `MA` a credential, and it is the particle's surname. diff --git a/docs/design/decisions/R1-fields-are-views-of-the-parsed-words.md b/docs/design/decisions/R1-fields-are-views-of-the-parsed-words.md new file mode 100644 index 00000000..c1c512a7 --- /dev/null +++ b/docs/design/decisions/R1-fields-are-views-of-the-parsed-words.md @@ -0,0 +1,22 @@ +# R1 — fields are views of the parsed words + +- 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, the next entry, 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 — 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 against d6087d9e (PR #435): `_segment_holds_no_name` ran twice per family-comma parse, pure recomputation (408 of 506 comma-bearing test literals reached both sites, 0 disagreements), and removing the second call was worth 1.2–2.2% of a family-comma parse and 0% of every other, so the number was recorded instead of a cache. + 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), and that path has no writer-drawn boundary, so it is a separate question. CLOSED 2026-09-06 by #436/#437, the entry after this one, which moved the boundary to the comma the writer typed and pinned the round trip (`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 example; a fourth 1.4.0 ledger rule written for it explained nothing and was dropped, #373's dormant-rule diagnosis doing its job (the ledgers were retired in #664). +- 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 mechanisms.md#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; it was filed under decisions/C1 until #728 gave R1 a decisions file of its own. + 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; decisions/C9's #430 entry 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 against f5deae01 (PR #510), compare.py at each baseline: the gate exits 0 with nothing unexplained; 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, the corpus growing by one name, the R1 example `John Smith MD PhD`; one name, `abdul Smith Jr V`, changes ledger rule, at 2.0.0 and 2.1.0. + VIEWS, over the 1117 corpus names against f5deae01 (PR #510): 13 of the 1116 pre-existing names move, 14 with the new R1 example, each in `suffix_list` and the v2 suffix field and tags only, and every one from a comma-joined render to a space-joined one. + RE-PINNED (PR #510): nine `cases.py` rows (`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 four CJK honorific rows), eight assertions outside the table (four in the v1-parity suite, a deliberate v1 deviation the release log names) and four rules example lines (P2's `John Smith Mc V`, P5's `abdul Smith Jr Ma`, rules/C9's `Smith, John PhD I.` and W3's `田中さん 様.`), each losing a comma the writer never typed. + 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 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. Answered, and FIXED, by #511 — the next bullet. + 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. +- 2026-09-06 #511 — a suffix value handed to `revise()` derives its entries from its own commas, by the rule a whole name uses. `Parser.revise` sub-parsed each value and forced every harvested token to the named role AFTER the sub-parse had run R1's entry pass, which keys on Role.SUFFIX; a bare 'MD PhD' reads there as a title and a family name, so the pass joined nothing and the field rendered 'MD, PhD'. The fix runs the same pass again: `revise` now sub-parses to a ParseState, forces the role on every non-dropped token, and calls `suffix_entries` — the pass, lifted unedited out of post_rules' tail into a function the two callers share (mechanisms.md#ONE-PREDICATE-PER-QUESTION) — over the forced state, then assembles and harvests as before. So a comma in the value parts two credentials and a space joins them. TWO SPELLINGS of the lifted pass, and the reason is the call budget: a first draft had post_rules call the state-in/state-out wrapper, and the second ParseState build cost three more calls per parse against the band tests/v2/test_benchmark.py holds (py3.11, 2026-09-06, tools/perf/call_count.py: 450 calls/name before the move, 451 with the in-place worker `_mark_suffix_entries` that post_rules now calls, 454 with the draft; the facade band tops at 455.9), so the worker writes in place as every other post rule does and the wrapper exists for the one caller with no token list of its own. NO `_run` HELPER for the same reason: a draft routed `parse()` through a private state builder shared with `revise`, and that cost one frame per parse on the hot path (py3.11, 2026-09-06: 415 calls/name against 414 without it, in a 402-418 band), so the four-line construction is spelled twice and `parse()` is untouched. MEASURED 2026-09-06 over the 1117 distinct names in `tools/differential/corpus*.jsonl`, comparing `revise(p, suffix=p.suffix).suffix` against `p.suffix` under the default parser for every name with a non-empty suffix (368 of the 1117): 38 differed before and 1 after; the differential gate is byte-identical at all four baselines, `revise` not being on the compare path. RECOMPUTE: parse each corpus name, skip an empty suffix, revise the parse with its own suffix, count the names whose suffix moved (the script is in the #511 issue body). THE ONE LEFT is '김민준씨, J.씨', and it is not entry structure: the whole-name parse keeps 'J.씨' one glued suffix token, suffix '씨, J.씨', while the sub-parse of the bare value '씨, J.씨' peels the honorific off the initial, so the revised field renders '씨, J. 씨' — right entries, the spurious comma of before ('씨, J., 씨') gone, one word read differently by the value's own parse than by the whole name's. That is the "classified ON ITS OWN" limit `revise`'s docstring has always recorded, and CJK honorific peeling is a W-rule question this change does not move; pinned in `test_revise_reads_a_glued_honorific_on_its_own`. SUPERSEDES the phd-merge acceptance of 'Ph., D.' on this path (its bullet says how). STALE TAGS, tried and backed out: a draft cleared the sub-parse's own "joined" with the forcing and re-derived it, because the pass only ADDS the tag and `revise(n, family="Jones MD PhD")` carries the sub-parse's between-piece suffix mark on 'PhD' onto a FAMILY token. Measured 2026-09-06 against `330ee55`, the clear also destroyed every WITHIN-piece mark on a non-suffix value — 'D.' of `revise(n, family="John Ph. D. Smith")` lost the merge mark the #436 bullet's DECLINED list calls role-blind and correct for every role — while on a ParsedName only the suffix string view reads "joined" (`_text_for`'s suffix_join gate) — the facade's `_list_for` heals it for every role, but no path puts a revised name into a HumanName, the v1 setters going through `replace()`, so wiring those setters onto `revise` is the change that would show the stale mark, as `last_list == ['Jones', 'MD PhD']` — `initials()` and every field string being identical at both trees for every shape measured. So the tags are kept minus FOLDED_TAG as before; the between-piece mark on a forced non-suffix role is a tag-only oddity that predates this change and stays, and for a suffix value nothing depends on the sub-parse's marks, every pair it joined sharing a bucket with no parting token and the pass setting it again; pinned in `test_revise_keeps_the_sub_parses_within_piece_mark`. Dropped tokens keep their role and tags through the forcing, because the pass filters them by index and assemble omits them. ONE MORE LIMIT, pinned in `test_revise_leaves_a_policy_delimiter_unparted_without_a_tail_segment`, and it is the "classified ON ITS OWN" limit again rather than a rule of revise's: a delimiter the policy names through `extra_suffix_delimiters` is dropped, and so parts entries, only on a segment after a comma that the reading of the words makes a tail, and a value with no comma of its own has none — under `Policy(extra_suffix_delimiters=frozenset({" - "}))` the whole name 'Doe, John, MD PhD - FACS' renders 'MD PhD, FACS' while `revise(n, suffix="MD PhD - FACS")` renders 'MD PhD - FACS', the dash surviving as a token and the forced role making it a suffix word; measured 2026-09-06, a value whose own words read with a tail segment does part ('John Doe, MD - FACS' revises to 'John Doe, MD, FACS') and one after a suffix comma does not ('MD, PhD - FACS' stays), which is how the value's words read as a name deciding it. The round-trip is unaffected, the whole-name view having already rendered that boundary as a comma. DECLINED: a list-valued `revise(p, suffix=["MD PhD", "FACS"])`, which widens the API and leaves the string path where it was; documenting the limit with pins alone, the docstring already recording it and #511's measurement showing it reachable on every space-joined run #436 produced; and a text-only read of the delimiter cores inside `revise`, a second rule for a case no round-trip reaches. Out of scope and left as is: `ParsedName.replace(suffix="MD, PhD")` renders 'MD,, PhD', `replace` whitespace-splitting by contract and the facade setters riding on it for v1 parity. +- 2026-10-18 #728 — MOVED FROM decisions/C1. These entries decide that one comma segment is one suffix entry, which is R1's entry pass rather than the comma decision, and were filed under decisions/C1 until #728; they moved unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it. diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index aa00b9a0..79964555 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -61,6 +61,15 @@ Entry conventions: ## Commas & structure (C) - [C1 — the suffix-comma decision](C1-suffix-comma-decision.md) +- [C3 — counting the words before the comma](C3-counting-the-words-before-the-comma.md) +- [C4 — the ambiguous credential class at the comma](C4-ambiguous-credential-class-at-the-comma.md) +- [C5 — paired initials after the comma](C5-paired-initials-after-the-comma.md) +- [C6 — an unlisted all-caps word needs the name's case contrast](C6-unlisted-all-caps-word-needs-the-names-case-contrast.md) +- [C7 — a postnominal part is read before any join](C7-postnominal-part-is-read-before-any-join.md) +- [C8 — the listing form's part after the comma](C8-listing-forms-part-after-the-comma.md) +- [C9 — a one-character suffix word inside a comma part](C9-one-character-suffix-word-inside-a-comma-part.md) +- [C10 — a declared delimiter parts a trailing suffix part](C10-declared-delimiter-parts-a-trailing-suffix-part.md) +- [C11 — what the comma decision reports](C11-what-the-comma-decision-reports.md) ## Name order (O) @@ -89,6 +98,7 @@ Entry conventions: ## Rendering & views (R) +- [R1 — fields are views of the parsed words](R1-fields-are-views-of-the-parsed-words.md) - [R2 — the all-particles family_base divergence](R2-all-particles-family-base-divergence.md) - [R3 — initials honor the fold](R3-initials-honor-the-fold.md) - [R4 — case repair reads the unjoined mark](R4-case-repair-reads-the-unjoined-mark.md) diff --git a/docs/design/decisions/S10-trailing-roman-numeral.md b/docs/design/decisions/S10-trailing-roman-numeral.md index 32477623..d7a73a1b 100644 --- a/docs/design/decisions/S10-trailing-roman-numeral.md +++ b/docs/design/decisions/S10-trailing-roman-numeral.md @@ -11,7 +11,7 @@ - 2026-10-18, #718 review — THE COUNT IS NOT ASKED IN THE GIVEN PART AFTER A FAMILY COMMA. The code review's grid (family-comma and empty-head shapes behind a title-particle, about 558,000 parses) found 2,880 parses where the first form moved a numeral from the suffix into the given or family name with no report: `Smith, Freiherr von Berg V` read given 'von Berg V', `, Freiherr von V` family 'von V', `Smith, St van V` given 'van V', where `Smith, von V` and `Smith, Freiherr John V` kept suffix V. The cause was not a choice: assign's walk over that part never counts the numeral (rules/S4: the given part's trailing slot "is read without it (S5)"), but the particle chain's bound there asks `tail_reading`, which the count had reached, so the chain took a word assign would have read as the suffix. The bound now reads with the count off (`trailing_run_start`, `count_numeral=False`), and all four read as they did on master (2.2.0 onward, since #424; 1.4.0 read the numeral into the name). The class is the one the AGENTS.md gotcha on group-side predictors of the given part names, met from the other side: the predictor did not change, the peel it asks did. The S4 analogue, `Smith, Freiherr von Berg Ma` → given 'von Berg Ma', is master's reading and is left alone here. - 2026-10-18, #718's PR review — TWO OF THE THREE REPORT SITES ARE ONE, AND THE CLAUSE ASKS THE CASE. The test-coverage review mutated each new emitter out in a copy of the tree. Removing the particle chain's numeral report moved no field and no kind — assign's pick loop reported the same `suffix-or-name`, worded from the final role ('read as a family name') — so the chain's branch was only choosing a sentence, and it is gone: a declined numeral stays one of the read's picks, and assign alone reports it, the decision having been the read's and not the chain's. The maiden clause's stand-down on a letter already reported as an initial could not fire at all (classify reads no connective inside a clause), so that call is gone too. And the clause's report on a numeral its take gave up did not ask the case: `Jane Doe nee Smith vi` and `x` reported where every other path had gone silent, the drift mechanisms.md#ONE-PREDICATE-PER-QUESTION names; the take now asks `numeral_settled_by_case` as assign does, `VI` still reporting beside them. Five rows the first draft labelled `fix(#718)` read field for field as 1.4.0 does and are `parity`, as are the two Freiherr rows added here; a restored 1.4.0 reading is parity by the table's own definition (rules/S4's `bare_surname_is_not_a_credential` is the precedent). - 2026-10-18 (Derek), #721 — A ONE-CASE ONE-LETTER NUMERAL IS THE INITIAL ITS CAPITAL IS, AND A LETTER ENDING ITS PART IS NO CONNECTIVE. Two Accepted gaps of the entries above close. First, `john v` read suffix 'v' with no family while `JOHN V` and `john x` were counted: `V` carries the `initial` tag (a bare capital) and every reader of the trailing run honors it — the suffix veto, the particle chain, the credential run — while a lowercase `v` was a plain listed suffix word. A first prototype (not shipped) routed a one-case `v` to the numeral fork at the walk's last position only, and a lower/upper invariant — every one-case name parses as its capitals, fields and report kinds — showed it too shallow (`john v phd`, `john v, phd` and `freiherr von smith v` still disagreed). The fix that holds tags the letter where its capital is tagged: in a name written in one case, a one-letter roman numeral is an `initial` (classify, reading the NAME-level one-case fact so a maiden clause's `v` is one too, as #516's caps branch reads it). And #721's reviews found the numeral fork's own test of the word in front case-dependent: a bare lowercase letter is never initial-shaped, so `john j v` read suffix 'v' where `JOHN J V` reads family 'V' (on master too). In a one-case name a bare cased letter in front now counts as written as an initial. The invariant is `test_a_one_case_name_reads_as_its_capitals` in tests/v2/test_properties.py, its grid and its two recorded controls stated there: master disagreed on 288 of its 1,575 pairs, the branch on none. - Second, Derek on the issue: `john i` reporting `conjunction-or-initial` is wrong "because there's nothing for a conjunction to join to there". P3 already said a connective with nothing to its right connects nothing; classify's report now skips a letter that is the last own word of its part — before a comma, a marker's clause or the end. Such a letter is read as an initial and not reported, and where it is a roman numeral the numeral fork reports `suffix-or-name` instead (`john i`, `HENRY I`, `carod i`); a marked letter that is no numeral is simply silent (`john e`, `jones, john e`). Not all such letters are the walk's last word: a trailing title (rules/H5) is an own word to classify that the walk peels first, so in `john i dr.` the letter keeps P3's report and is still the numeral fork's declined pick. The stand-down the entry above added (a letter already reported as an initial reports nothing new) is what keeps that to one report, and it stays: a first draft of this change deleted it as dead on a mutation run over an intermediate tree, and both reviews then found `john i dr.`, `JOHN I DR.` and `henry i prof.` carrying both reports on one token, where master carries `conjunction-or-initial` alone. Two comma rows go silent rather than move — `rovira, i` and `maier, amy i, jr.` — since the comma path has never reported the numeral (decisions/C1, "Not done"). + Second, Derek on the issue: `john i` reporting `conjunction-or-initial` is wrong "because there's nothing for a conjunction to join to there". P3 already said a connective with nothing to its right connects nothing; classify's report now skips a letter that is the last own word of its part — before a comma, a marker's clause or the end. Such a letter is read as an initial and not reported, and where it is a roman numeral the numeral fork reports `suffix-or-name` instead (`john i`, `HENRY I`, `carod i`); a marked letter that is no numeral is simply silent (`john e`, `jones, john e`). Not all such letters are the walk's last word: a trailing title (rules/H5) is an own word to classify that the walk peels first, so in `john i dr.` the letter keeps P3's report and is still the numeral fork's declined pick. The stand-down the entry above added (a letter already reported as an initial reports nothing new) is what keeps that to one report, and it stays: a first draft of this change deleted it as dead on a mutation run over an intermediate tree, and both reviews then found `john i dr.`, `JOHN I DR.` and `henry i prof.` carrying both reports on one token, where master carries `conjunction-or-initial` alone. Two comma rows go silent rather than move — `rovira, i` and `maier, amy i, jr.` — since the comma path has never reported the numeral (decisions/C9, "Not done"). WHAT MOVES, the snapshot against master at 2bfa9533: six corpus names, `john v` (family 'v'), `john smith v` and `larry james edward johnson v` (gain the report), `doe, jane v phd do` (middle 'v', suffix 'phd do', as `DOE, JANE V PHD DO` and `Doe, Jane V PhD Do` read; rules/S7's example keeps its input, whose point — `do` anchored by the degree in front — is unchanged), `john i` (report kind), `jones, john e` (report dropped). And a seventh reading the snapshot shows as a removal rather than a move, since the rules example that pinned it was deleted with S10's Accepted clause: `john nee smith i` read suffix 'i' and now reads maiden 'smith i', reported — the clause-free name is `john i`, whose letter has no word to spare, so M2's clause keeps it, as it keeps `john nee smith v`. And decisions/P3's off switch no longer restores 2.3.0's three one-case readings (that file's #721 entry). - 2026-10-18 (Derek), #721's PR review — A NUMERAL BEFORE A CREDENTIAL IS REPORTED, AND THE TWO-WORD COUNT STANDS. Derek: `john i phd` should report `suffix-or-name` as `john i, phd` does, and `John Smith V PhD` keeps family 'V' with the report — so the Accepted clause's reading stands and only the report is added; the fields do not move (1.4.0 read the same). The part's last name word with nothing but the trailing run behind it is the site: assign, once the trailing read has said what that run is, withdraws classify's connective report on a marked letter there (rules/P3 now says "unless no name word of its part stands to its right") and reports a roman numeral there, worded for what follows it. So `john i dr.` reports the numeral once, `john e phd` and `jose e jr` report nothing, and `john smith i jr` reports `suffix-or-name` where it reported `conjunction-or-initial`. #718's stand-down is kept as a defensive check, and says so: measured on this commit's tree, the full suite and a 1,680-parse trailing-title sweep fail and pass alike without it. Declined: two stricter readings Derek weighed on the PR, both measured over the snapshot corpus. (A) a one-letter numeral is the generation only when given, middle and family are all filled — 11 readings move, among them `Hardie Leannon V`; (B) a comma-less one-letter numeral is never the generation — 18 move, among them `John W. Ingram V`. Both are real people who write the numeral without a comma: #36 (2015) reported `Hardie Leannon V` reading last 'V' as a bug, #136 (2022) came from John W. Ingram V writing his name with and without the comma, and Derek found the same sheriff written `John W Ingram V` and, once, `John Ingram V` (Brunswick Beacon, his candidate profile). The two-word count reads all four as the generation; the rows `a_real_generation_with_a_middle_initial` and `a_real_generation_with_no_middle_name` pin the last two. diff --git a/docs/design/decisions/S7-company-of-an-unambiguous-credential.md b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md index 1ab8589e..95dbcd6c 100644 --- a/docs/design/decisions/S7-company-of-an-unambiguous-credential.md +++ b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md @@ -1,6 +1,6 @@ # S7 — the company of an unambiguous credential -- 2026-09-27 (Derek), #544 — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS: C1'S NAME-WORD COUNT READS A RUN AS IT READS ONE WORD, AN UNAMBIGUOUS CREDENTIAL IN FRONT OF A MEMBER SPEAKS FOR IT AT EVERY TRAILING SLOT, AND A LISTED MEMBER'S CHUNKED DOTTED SPELLING PASSES THE PERIOD GATE. Three readers never looked past the member itself: C1 flipped a part to the suffix comma by the name-word count only when it was ONE token, and the trailing readers — the no-comma peel, the comma part read wholly as credentials, the given part's trailing slot — met the member first, took its name lean and stopped, so the credential in front of it was never reached. #540 marked `meng` and `lac`, whose conventional spellings lean name, which turned the one-case-row cost decisions/S6's 2026-09-15 item (ii) accepted into the ordinary spelling of two degrees. DECIDED, ten forks, (9) and (10) on 2026-09-28. (1) THE WHOLE RUN AT C1: two or more words after the comma, every one suffix vocabulary or a class candidate, at least one a candidate, behind two or more name words, is the credential run — all-member runs (`John Smith, Ed Ma`) and a leading member (`John Smith, MEng PhD`) included; offered and declined were an anchored-only rule and the issue's own "last word ambiguous" wording, which contradicted its `Ed Ma` consequence. (2) ALL TRAILING PATHS, ANCHORED IN FRONT: a listed member with an unambiguous credential in front of it, through other members, reads as the credential on the no-comma peel, in the comma part read wholly as credentials and at the given slot; a credential BEHIND it anchors nothing (`Wang Ma PhD` keeps family 'Ma'). (3) THE CHUNKED DOTTED GATE: a listed member written in two or more period-closed letter chunks (`M.Eng.`, `L.Ac.`) passes as `M.A.` does; one trailing period (`Ma.`, `Ed.`) still does not. (4) SINGLE-LETTER ROMAN NUMERALS neither anchor nor count toward a C1 run, S3's single-character retirement being the precedent; they peel as before, and the anchor's half of the exclusion is a matter of their shape, boundary (d) below. (5) NO NEW REPORT WHERE THE WRITING SETTLED IT: a run whose every member is LISTED and leans credential — S2's lean, capitals in a mixed-case name — is not flipped, the family-comma path already reading it as the credential run with the fields and the report it always had (`John Smith, PhD MA`) — narrowed 2026-10-01 by the #562 entry under C1: a part holding two particles side by side is read by the count instead, and reports; the alternative, flipping such a run and suppressing only the flip's report, removes reports the family-comma path makes today, `John Smith, MA Jr` losing the one it makes for 'MA'. Because the lean is the listed set's, a by-shape member in capitals never settles a run: `John Smith, X.Y.Z. MA` flips to suffix 'X.Y.Z. MA' and reports. (6) THE ANCHOR DOES NOT REACH ACROSS A MAIDEN CLAUSE: `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' where `Jane Doe Jr. Ma` reads suffix 'Jr. Ma' — rules/M2's Accepted boundary, and the M2 bullet of this date. (7) AN ANCHOR OVERRIDES P6 AT THE GIVEN SLOT: `doe, jane v phd do` reads suffix 'v phd do' where it read family 'do doe'; the bullet below amends #531's pairing. (8) THE DUAL EXCLUSION IS SCOPED TO THE GIVEN PART'S LEADING TITLE RUN, NOT THE C1 RUN: after a full name the dual counts as the suffix vocabulary it is, as the legacy disjunct already counted it (`John Smith, MS MA`, `John Smith, MD`), and that is what reads the issue's headline `Jane Doe, MS LAc` as suffix 'MS LAc'. (9) A MEMBER THE COMPANY DECIDES REPORTS WHERE IT WAS DECIDED, in a family comma's part read wholly as credentials as at the peel: each member whose own writing declined and which the credential in front made the credential reports `suffix-or-name` at that reading. `Smith, PhD Ma`, `Smith, PhD MEng` and `Smith, Ph. D. MEng` report the member; `Smith, Dr. PhD LAc` and `John Quincy Smith, Prof. PhD LAc` report 'LAc'; `john smith, v phd ma` reports 'ma'; `John Smith, PhD Ma Prof.`, which C1 does not flip for its trailing title, reports 'Ma' where its twin `John Smith, PhD Ma` reports C1's flip once over the part. A member its own capitals made the credential stays silent (`Smith, PhD MA`), and the part's first piece, which nothing stands in front of, is the first-slot report's word and never this one's. (10) A DUAL IN THE GIVEN PART'S LEADING TITLE RUN SILENCES THE WHOLE PART: refinement (b) below reads such a dual as a title that speaks for nothing, and in a part whose leading title run holds one no credential behind the dual speaks for a member of the ambiguous class either, so the company never reads such a part wholly as credentials: the titles give it its reading, the dual a title and the next word the given name, and behind that given name the given slot reads as it reads any (`Smith, MD PhD Jr Ma` reads suffix 'Jr Ma', as `Smith, MD Jane Jr Ma` does). A part with no member to speak for, or only members whose capitals decide them, reads as it did: `Smith, MD PhD` suffix 'MD PhD', `Smith, Ms MD MA` suffix 'Ms MD MA'. `Smith, Ms MD Ma`, `Smith, MD MS Ma` and `SMITH, MD MS BA` read title 'Ms MD' / 'MD MS', given 'Ma' / 'BA'; `Smith, MD PhD Ma` reads title 'MD', given 'PhD', middle 'Ma', the given slot reporting 'Ma'; e10e83b4 read each of them so. The run is the walk's leading title run, so a dual behind a plain title stands in it too (`Smith, Prof. MD Ma` and `smith, prof. md ma` read title 'Prof. MD', given 'Ma'; `Smith, Dr. MD PhD Ma` keeps given 'PhD'), while a plain title alone silences nothing (`Smith, Dr. PhD LAc` reads title 'Dr.', suffix 'PhD LAc'). Behind a real given name the company reads as ever (`Doe, Jane MD Ma` and `Doe, Jane Ms Ma` read suffix 'MD Ma' and 'Ms Ma'), and past the title run a dual speaks like any suffix word (`Smith, PhD Ms Ma` reads suffix 'PhD Ms Ma'); `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` are unchanged. +- 2026-09-27 (Derek), #544 — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS: C1'S NAME-WORD COUNT READS A RUN AS IT READS ONE WORD, AN UNAMBIGUOUS CREDENTIAL IN FRONT OF A MEMBER SPEAKS FOR IT AT EVERY TRAILING SLOT, AND A LISTED MEMBER'S CHUNKED DOTTED SPELLING PASSES THE PERIOD GATE. Three readers never looked past the member itself: C1 flipped a part to the suffix comma by the name-word count only when it was ONE token, and the trailing readers — the no-comma peel, the comma part read wholly as credentials, the given part's trailing slot — met the member first, took its name lean and stopped, so the credential in front of it was never reached. #540 marked `meng` and `lac`, whose conventional spellings lean name, which turned the one-case-row cost decisions/S6's 2026-09-15 item (ii) accepted into the ordinary spelling of two degrees. DECIDED, ten forks, (9) and (10) on 2026-09-28. (1) THE WHOLE RUN AT C1: two or more words after the comma, every one suffix vocabulary or a class candidate, at least one a candidate, behind two or more name words, is the credential run — all-member runs (`John Smith, Ed Ma`) and a leading member (`John Smith, MEng PhD`) included; offered and declined were an anchored-only rule and the issue's own "last word ambiguous" wording, which contradicted its `Ed Ma` consequence. (2) ALL TRAILING PATHS, ANCHORED IN FRONT: a listed member with an unambiguous credential in front of it, through other members, reads as the credential on the no-comma peel, in the comma part read wholly as credentials and at the given slot; a credential BEHIND it anchors nothing (`Wang Ma PhD` keeps family 'Ma'). (3) THE CHUNKED DOTTED GATE: a listed member written in two or more period-closed letter chunks (`M.Eng.`, `L.Ac.`) passes as `M.A.` does; one trailing period (`Ma.`, `Ed.`) still does not. (4) SINGLE-LETTER ROMAN NUMERALS neither anchor nor count toward a C1 run, S3's single-character retirement being the precedent; they peel as before, and the anchor's half of the exclusion is a matter of their shape, boundary (d) below. (5) NO NEW REPORT WHERE THE WRITING SETTLED IT: a run whose every member is LISTED and leans credential — S2's lean, capitals in a mixed-case name — is not flipped, the family-comma path already reading it as the credential run with the fields and the report it always had (`John Smith, PhD MA`) — narrowed 2026-10-01 by decisions/C4's #562 entry: a part holding two particles side by side is read by the count instead, and reports; the alternative, flipping such a run and suppressing only the flip's report, removes reports the family-comma path makes today, `John Smith, MA Jr` losing the one it makes for 'MA'. Because the lean is the listed set's, a by-shape member in capitals never settles a run: `John Smith, X.Y.Z. MA` flips to suffix 'X.Y.Z. MA' and reports. (6) THE ANCHOR DOES NOT REACH ACROSS A MAIDEN CLAUSE: `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' where `Jane Doe Jr. Ma` reads suffix 'Jr. Ma' — rules/M2's Accepted boundary, and the M2 bullet of this date. (7) AN ANCHOR OVERRIDES P6 AT THE GIVEN SLOT: `doe, jane v phd do` reads suffix 'v phd do' where it read family 'do doe'; the bullet below amends #531's pairing. (8) THE DUAL EXCLUSION IS SCOPED TO THE GIVEN PART'S LEADING TITLE RUN, NOT THE C1 RUN: after a full name the dual counts as the suffix vocabulary it is, as the legacy disjunct already counted it (`John Smith, MS MA`, `John Smith, MD`), and that is what reads the issue's headline `Jane Doe, MS LAc` as suffix 'MS LAc'. (9) A MEMBER THE COMPANY DECIDES REPORTS WHERE IT WAS DECIDED, in a family comma's part read wholly as credentials as at the peel: each member whose own writing declined and which the credential in front made the credential reports `suffix-or-name` at that reading. `Smith, PhD Ma`, `Smith, PhD MEng` and `Smith, Ph. D. MEng` report the member; `Smith, Dr. PhD LAc` and `John Quincy Smith, Prof. PhD LAc` report 'LAc'; `john smith, v phd ma` reports 'ma'; `John Smith, PhD Ma Prof.`, which C1 does not flip for its trailing title, reports 'Ma' where its twin `John Smith, PhD Ma` reports C1's flip once over the part. A member its own capitals made the credential stays silent (`Smith, PhD MA`), and the part's first piece, which nothing stands in front of, is the first-slot report's word and never this one's. (10) A DUAL IN THE GIVEN PART'S LEADING TITLE RUN SILENCES THE WHOLE PART: refinement (b) below reads such a dual as a title that speaks for nothing, and in a part whose leading title run holds one no credential behind the dual speaks for a member of the ambiguous class either, so the company never reads such a part wholly as credentials: the titles give it its reading, the dual a title and the next word the given name, and behind that given name the given slot reads as it reads any (`Smith, MD PhD Jr Ma` reads suffix 'Jr Ma', as `Smith, MD Jane Jr Ma` does). A part with no member to speak for, or only members whose capitals decide them, reads as it did: `Smith, MD PhD` suffix 'MD PhD', `Smith, Ms MD MA` suffix 'Ms MD MA'. `Smith, Ms MD Ma`, `Smith, MD MS Ma` and `SMITH, MD MS BA` read title 'Ms MD' / 'MD MS', given 'Ma' / 'BA'; `Smith, MD PhD Ma` reads title 'MD', given 'PhD', middle 'Ma', the given slot reporting 'Ma'; e10e83b4 read each of them so. The run is the walk's leading title run, so a dual behind a plain title stands in it too (`Smith, Prof. MD Ma` and `smith, prof. md ma` read title 'Prof. MD', given 'Ma'; `Smith, Dr. MD PhD Ma` keeps given 'PhD'), while a plain title alone silences nothing (`Smith, Dr. PhD LAc` reads title 'Dr.', suffix 'PhD LAc'). Behind a real given name the company reads as ever (`Doe, Jane MD Ma` and `Doe, Jane Ms Ma` read suffix 'MD Ma' and 'Ms Ma'), and past the title run a dual speaks like any suffix word (`Smith, PhD Ms Ma` reads suffix 'PhD Ms Ma'); `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` are unchanged. WHAT MAY ANCHOR, four boundaries of the company clause, each a rule. (a) A CONNECTIVE (P3) anchors nothing and ends the run: the generational `i` is also Catalan's conjunction, and `Jane Doe nee Puig i Ma` keeps its link, maiden 'Puig i Ma'. (b) A TITLE/SUFFIX DUAL IN THE GIVEN PART'S LEADING TITLE RUN after a one-word family comma is a title there and anchors nothing — and, by (10), no credential behind it speaks for a member in that part — so the given slot's pass starts past the leading title run: `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` keep their given names, and `Smith, MD MA Ma` keeps middle 'Ma', no unambiguous credential standing in its run once the title is set aside. Everywhere else a dual anchors like any suffix word (`John Smith MD MEng`, and (8)). (c) THE WALK'S OWN LEADING PIECE NEVER ANCHORS. In a name with no comma before the run, the leading piece is the name word H4's carve-out keeps whatever its vocabulary, and letting it anchor spends that name: in `Om Ma` and `PhD Ma` 'Ma' stays a name word under every name order (family 'Ma' in the default order, given 'Ma' under both family-first orders), where an anchoring `PhD` would leave given 'PhD', suffix 'Ma' and no family at all. One name word in front frees the same word — `John Om Ma` reads suffix 'Om Ma'. A family comma's given part has no such position, its first piece being read by the comma part's own credential reading, so `Smith, PhD MEng` reads family 'Smith', suffix 'PhD MEng'. (d) A SINGLE-LETTER ROMAN NUMERAL, IN ANY CASE (`V`, `v`, `I.`), anchors nothing for its SHAPE — one letter is how a middle initial is written, and S3 retired single-character vocabulary matches for the same reason (`John Smith PhD V Ma` keeps family 'Ma') — not because a generation is no credential. The rule is stated by that shape and the code tests exactly it (`_vocab.is_single_letter_numeral`); "initial-shaped" was the wrong word for it, `is_initial_shaped` answering False for a lower-case `v` that the exclusion covers too. And the exclusion is not about generations: a multi-letter numeral or generational word anchors as any suffix word does (`John Smith PhD III Ma` reads suffix 'PhD III Ma', `abdul Smith Jr Ma` suffix 'Jr Ma'). ACCEPTED COSTS, each a reading this design chooses. `John Smith, Ms Ma` → suffix 'Ms Ma', reported, as `John Smith, Ms` alone already reads suffix (8). `Om Jr Ma` → given 'Om', suffix 'Jr Ma' and no family name: 'Jr' is not the leading piece, so it anchors 'Ma', and rules/S2's standing Accepted clause already consumes an unambiguous suffix that leaves no family (`Om Jr` → given 'Om', suffix 'Jr'); every release from 2.0.0 made that same role assignment. `doe, jane v phd do` reports `suffix-or-name` where it reported `particle-or-given`, the fork reported being the credential pick rather than P6's attachment (7). And a one-word family comma whose given part OPENS with a capitalised `MA` in a mixed-case name, with an unambiguous credential behind it, reads the whole part as a credential run, family 'Smith' and no given name, where the parent kept given 'MA': `Smith, MA PhD Ma` read given 'MA', middle 'Ma', suffix 'PhD' and reads suffix 'MA PhD Ma'. 'MA' leans credential on its capitals, so it is read as the credential it is written as, and what follows it is then the company's (or, for `M.Eng.`, the chunked gate's). Measured 2026-09-28 on the grid below: 138 parses (46 texts, three orders each) go from given 'MA' to no given name, 72 of them with no `M.Eng.` in the run; 12 of the 138 are the parses the attribution below leaves unattributed, the rest falling in the anchor and `M.Eng.` classes. Each reports 'MA' at the first slot, and a member behind the credential reports as the company's pick (9). ACCEPTED LIMITS, each keeping the member a name word, rules/S2's Accepted block and case rows: the merged `Ph. D.` is outside the no-comma walk (`John Smith Ph. D. MEng` family 'MEng'; the comma and given-slot spellings do anchor it, `Smith, Ph. D. MEng` and `Doe, Jane Ph. D. MEng` reading suffix 'Ph. D. MEng'), a title between the credential and the member breaks the run (`John Smith PhD Prof. Ma`), and a particle member P2 has chained before the peel is out of reach (`John Smith PhD Do Do` family 'Do Do'; `Smith, PhD Do Ma` given 'PhD', middle 'Do Ma'). diff --git a/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md b/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md index 92a73032..980e7008 100644 --- a/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md +++ b/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -13,7 +13,7 @@ - 2026-09-18 — RULES.MD STATEMENTS NEVER NAME A POLICY FIELD, AND THIS ARC MADE THAT LOAD-BEARING. rules.md's own preamble binds statements to be implementation-free ("no stage names, no function names, no regexes"), and no existing statement names a `Policy` field either — C1 says "strict mode" and leaves `strict-comma-suffixes` to the annotation slot. S2's and S3's switch clauses follow that precedent rather than setting a new one: the prose describes `unlisted_dotted_suffixes` and `unlisted_caps_suffixes` as caller configuration and points at the example lines, and the ANNOTATIONS carry the exact field names (`unlisted_dotted_suffixes-off`, `unlisted_caps_suffixes-on`, registered in `tests/v2/rules_doc.py`'s `POLICIES` beside `strict-comma-suffixes`). Recorded here rather than as a silent edit to the preamble or to two rules' prose: if a future design wants a field named directly in a statement, that is a preamble amendment for someone to propose, not a convention this entry changes on its own authority. - 2026-09-18, corrected — `BUILD_RULES_CORPUS.PY` HARVESTING A POLICY-ANNOTATED-ONLY EXAMPLE IS LONG-STANDING, NOT NEW, AND THIS ARC ADDS TWO MORE INSTANCES OF IT. Its own docstring says "Policy annotations are deliberately ignored... a family-first-scoped example is simply one more name to diff" — an earlier draft of this bullet claimed that sentence was written when every annotated example had a default-policy sibling; measured false, at the commit that wrote it and on this tree alike. Recompute: parse every rule's examples with `tests/v2/rules_doc.py`'s own `parse_rules_doc`, group by `text` across the WHOLE document, and keep every text whose every appearance's annotation resolves (`resolve_annotation`) to kind `"policy"` rather than `"locale"`/`"gated_locale"` and never appears bare. That gives **19** pre-existing examples (`Mesnil de`, `Smith, de Mesnil Jean`, `de la Vega y Santos Juan`, `ibn Awf abdul Rahman`, `Nguyen Thi Van`, `Jane Smith (Nee)`, `Jane Smith (Nee) (Jones)`, `Garcia Juan Carlos`, `Nguyễn Thị Minh Khai`, `Del Toro`, `Anh Van Do`, `Beethoven Ludwig van`, `Berg Jan de Jr.`, `Garcia`, `Ménil Christophe de`, `Ménil de`, `Smith (Jones)`, `de Anke van y`, `van Berg Jan de`) plus this arc's own **2** (`Jean DUPONT`, `Jean Pierre DUPONT`, both `unlisted_caps_suffixes-on`, rules/S2) — **21** total on this tree. So the shape was accepted the day the docstring was written, not discovered here; what this arc adds is two more members, checked here for the reason the advice below names: `Jean Pierre DUPONT`'s DEFAULT reading (given Jean, middle Pierre, family DUPONT — measured; the annotated ON reading, given Jean, family Pierre, suffix DUPONT, is the only one rules.md states) never appeared in the doc before, and it produces no diff at any of the five baselines either way. Recorded per the same over-collection principle that keeps `(` and `.,` in the corpus (`tools/differential/README.md`'s provenance table): an annotated example's TEXT is a default-policy corpus name regardless of its annotation, full stop, and every one of the 21 — not only this PR's two — is a standing instance a reader should check for a default-policy diff on before assuming it free. - 2026-09-18 — THE COMMA HARNESS AND THE FRAME BAND, AFTER THE ROUND. Unchanged at the default: 412/449 on the reference name (`uv run python tools/perf/call_count.py`), and the module-`parse` totals in decisions/S6's WHERE THE FACT LIVES entry hold exactly — `John Smith` 176, `Smith, John` 207, `John Smith, Jr.` 233, `Smith, John, Jr.` 257, `Smith, MA` 225. WITH SWITCH B ON the two figures recorded there each drop by one: `Smith, John` 213 → 212 and `Smith, XYZ` 245 → 244, `Parser().parse` on both sides as before. The cause is the dead caps branch leaving `ambiguous_class_candidate`: `segment`'s single-token call used to reach a `caps_shape_candidate` call that could only ever answer False, and now does not. Recompute with that entry's recipe. -- 2026-09-30 (Derek), #563 — PAIRED INITIALS ARE THE EXCEPTION TO C1'S NAME-WORD COUNT, AND A FLIP ON THE DOTTED SHAPE ALONE IS SILENT. The 2026-09-14 (Derek) entry above, #516's DOTTED HALF IS A SWITCH, read `John Smith, A.B.` as suffix 'A.B.' on the count of two name words before the comma. The count cannot tell a given name and a family name from ONE surname written in two words, and the dotted shape's own most common member is a person's initials, so the unreleased tree read `García Márquez, G.J.` as given 'García', family 'Márquez', suffix 'G.J.', `De La Cruz, M.J.` with no given name at all, and `van der Berg, A.J.` as given 'van' — where 1.4.0 through 2.3.0 read each as initials. A particle check would have caught only the last two; `García Márquez` and `Lloyd Webber` look exactly like `John Smith`. What the parser CAN see is the token, so the line is drawn there: two single letters joined by a period, with or without one after the second (`_vocab.is_paired_initials`; `García Márquez, G.J` reads as `G.J.` does), read as the given name after the comma and report the fork, and only an UNAMBIGUOUS suffix word IN FRONT of them that is not also title vocabulary, or a second word the class admits by dotted shape in the same part, makes them the credential run (`John Smith, PhD X.Y.`, `John Smith, X.Y. P.Q.`). Three letters or more (`X.Y.Z.`) and any longer chunk (`B.Tech.`) read by the count as before. Three choices made with it (Derek, 2026-09-30): (i) FRONT ONLY — a word behind the pair does not speak, so `De La Cruz, M.J. PhD` keeps given 'M.J.' and `John Smith, A.B. PhD` reads given 'A.B.', the direction rules/S2's company clause already takes; a LISTED dotted word behind does not speak either (`John Smith, A.B. Ph.D.` → given 'A.B.'), the second-word exception being for words the class admits by shape; (ii) SILENT FLIP — a flip resting only on words the class admits by dotted shape reports nothing, `B.Tech.` included, because the only such word a reader takes for a name is a pair of initials, and the comma flips a pair only where a word beside it has already said otherwise; a LISTED member in the same part still reports the flip (`John Smith, PhD MEng`, `John Smith, X.Y.Z. MA`); (iii) THE COMMA SLOT ONLY — the trailing slot (`John Smith R.T.`), the given part's trailing slot after a family comma (`Doe, John R.T.`) and the maiden clause keep #516's reading and its report; #563 leaves the no-comma question open. A generational word in front speaks like any unambiguous suffix word (`García Márquez, Jr. G.J.` → suffix 'Jr. G.J.'), as S2's company clause has it — unless it is also a title, so `García Márquez, Sr G.J.` reads title 'Sr', given 'G.J.'. ACCEPTED: `García Márquez, G.J.R.` reads suffix 'G.J.R.' — initials are conventionally written apart, as separate words this rule never reads (`García Márquez, G. J. R.` gives given 'G.', middle 'J. R.'), and three letters run together are far likelier a credential. REVERSED BY THIS ENTRY rather than edited, for the dotted half at the first slot after a comma only: the #516 SWITCH entry's `John Smith, A.B.` → suffix and its "reports either way"; the 2026-09-14 NO NEW AmbiguityKind entry's "Every decision at these slots emits" SUFFIX_OR_NAME; and rules/C1's "A decision either way at this comma is reported". ALSO FIXED: the trailing slot's report described a by-shape pick in the listed member's words ("written without periods is both a post-nominal"), false of `X.Y.Z.` on both counts; it now says the word is shaped like a post-nominal but listed in no vocabulary. +- 2026-09-30 (Derek), #563 — PAIRED INITIALS ARE THE EXCEPTION TO C1'S NAME-WORD COUNT, AND A FLIP ON THE DOTTED SHAPE ALONE IS SILENT. The 2026-09-14 (Derek) entry above, #516's DOTTED HALF IS A SWITCH, read `John Smith, A.B.` as suffix 'A.B.' on the count of two name words before the comma. The count cannot tell a given name and a family name from ONE surname written in two words, and the dotted shape's own most common member is a person's initials, so the unreleased tree read `García Márquez, G.J.` as given 'García', family 'Márquez', suffix 'G.J.', `De La Cruz, M.J.` with no given name at all, and `van der Berg, A.J.` as given 'van' — where 1.4.0 through 2.3.0 read each as initials. A particle check would have caught only the last two; `García Márquez` and `Lloyd Webber` look exactly like `John Smith`. What the parser CAN see is the token, so the line is drawn there: two single letters joined by a period, with or without one after the second (`_vocab.is_paired_initials`; `García Márquez, G.J` reads as `G.J.` does), read as the given name after the comma and report the fork, and only an UNAMBIGUOUS suffix word IN FRONT of them that is not also title vocabulary, or a second word the class admits by dotted shape in the same part, makes them the credential run (`John Smith, PhD X.Y.`, `John Smith, X.Y. P.Q.`). Three letters or more (`X.Y.Z.`) and any longer chunk (`B.Tech.`) read by the count as before. Three choices made with it (Derek, 2026-09-30): (i) FRONT ONLY — a word behind the pair does not speak, so `De La Cruz, M.J. PhD` keeps given 'M.J.' and `John Smith, A.B. PhD` reads given 'A.B.', the direction rules/S2's company clause already takes; a LISTED dotted word behind does not speak either (`John Smith, A.B. Ph.D.` → given 'A.B.'), the second-word exception being for words the class admits by shape; (ii) SILENT FLIP — a flip resting only on words the class admits by dotted shape reports nothing, `B.Tech.` included, because the only such word a reader takes for a name is a pair of initials, and the comma flips a pair only where a word beside it has already said otherwise; a LISTED member in the same part still reports the flip (`John Smith, PhD MEng`, `John Smith, X.Y.Z. MA`); (iii) THE COMMA SLOT ONLY — the trailing slot (`John Smith R.T.`), the given part's trailing slot after a family comma (`Doe, John R.T.`) and the maiden clause keep #516's reading and its report; #563 leaves the no-comma question open. A generational word in front speaks like any unambiguous suffix word (`García Márquez, Jr. G.J.` → suffix 'Jr. G.J.'), as S2's company clause has it — unless it is also a title, so `García Márquez, Sr G.J.` reads title 'Sr', given 'G.J.'. ACCEPTED: `García Márquez, G.J.R.` reads suffix 'G.J.R.' — initials are conventionally written apart, as separate words this rule never reads (`García Márquez, G. J. R.` gives given 'G.', middle 'J. R.'), and three letters run together are far likelier a credential. REVERSED BY THIS ENTRY rather than edited, for the dotted half at the first slot after a comma only: the #516 SWITCH entry's `John Smith, A.B.` → suffix and its "reports either way"; the 2026-09-14 NO NEW AmbiguityKind entry's "Every decision at these slots emits" SUFFIX_OR_NAME; and rules/C1's "A decision either way at this comma is reported" (rules/C11's since #728). ALSO FIXED: the trailing slot's report described a by-shape pick in the listed member's words ("written without periods is both a post-nominal"), false of `X.Y.Z.` on both counts; it now says the word is shaped like a post-nominal but listed in no vocabulary. REVIEW ROUND, same day. The docs review found the first version let a word of both the title and the suffix vocabulary speak for the pair, since C1 counts such a word opening the part as a suffix word: `García Márquez, Ms G.J.` read given 'García', family 'Márquez', suffix 'Ms G.J.' with NO report — #563's own defect, made silent — where 2.3.0 read title 'Ms', given 'G.J.'. The twelve title/suffix duals (`ms`, `md`, `sr`, `lt`, `sa`, `ra`, `vc`, `cpl`, `cpo`, `cpt`, `csm`, `sgm`) all reached it. Fixed by letting only a suffix word that is not title vocabulary speak: in front of a pair, a dual is the title of the given part the pair opens, as S2 already reads a dual in that part's title run, and `Ms G.J.`, `MD G.J.`, `Lt G.J.` now read title plus given as every release did. The title lookup runs only once a pair is met. The code review's frame count found the single-token path asking `ambiguous_class_member` a second time, +2 frames on every reporting comma name (`John Smith, MA`, 252 → 254, profiler call events); for a word past the candidate test LISTED is exactly "no period" (`ambiguous_class_member` declines any period), which is now asked inline. Measured after both fixes against master, same counter: `Smith, John` 183 and `John Smith, MA` 252 on both trees, `John Smith, X.Y.Z.` 295 → 286 (the report it no longer builds), and `García Márquez, G.J.` 338 → 350, the family-comma reading and its report costing more than the flip did. SECOND REVIEW ROUND, same day, on the first round's fix commit. (a) TWO PAIRS (Derek): `De La Cruz, M.J. K.L.` read family 'De La Cruz', suffix 'M.J. K.L.' — no given name — in silence, the silent-flip rationale ("only paired initials are taken for a name") failing on exactly the case where each word speaking for a pair is itself a pair. The reading stands, two dotted groups not being how anyone writes a person's initials, but that flip now REPORTS: when the only words speaking for a pair are other pairs, every shape word in the part being one. `John Smith, X.Y. P.Q.` gains the report with it; `John Smith, X.Y.Z. G.J.` and `John Smith, PhD G.J. K.L.`, where something else speaks, stay silent. The same rule reports `García Márquez, Ms G.J. K.L.`, whose honorific the run takes, where the first round's fix took it in silence. (b) A CLASS MEMBER IN FRONT SPEAKS FOR NOTHING (Derek): `García Márquez, Ed G.J.`, `Ma G.J.` and `MA G.J.` flipped on the member in front, where S2's company lets only an unambiguous credential speak for a member. Now the comma keeps the family, given 'Ed' — and 'G.J.' then ends the given part, the slot (iii) left alone, so it reads suffix 'G.J.' as `Doe, John R.T.` does, both forks reported; 1.4.0 and 2.3.0 gave middle 'G.J.'. Resolving that slot is the no-comma question #563 leaves open. (c) WORDING, no behavior: C1's sentence counting a dual opening a part as a suffix word now defers to the pair's titles; the pair's report reaches it only where it opens the part, `Smith, Ms G.J.` having always been silent (the comma report's reach is the first post-comma piece, 2026-09-18); and the silence is stated as "no listed word takes part", which covers `García Márquez, PhD G.J.`, where "dotted-shape words alone" did not. SIMPLIFY ROUND, same day, behavior-identical (0 diffs over 21,604 parses: every corpus name, every quoted string in tests/v2/cases.py and 115 composed `pre, post` probes, under four policies, comparing fields and ambiguity details against the pre-round commit c125f69b; the same harness finds 252 diffs against master). One finding was a cost, not a style point: the speaker test scanned every word in front of EACH pair for a non-title, so `John Smith, MD MD ... G.J. G.J. ...` cost duals × pairs `_normalize` calls (163 at 8 of each, 1,387 at 32, py3.11). Only the first pair's scan can change the answer, since every later pair has the same words in front and more, so it is asked once: 107 and 395. `tests/v2/test_benchmark.py::test_the_paired_initials_title_scan_does_not_cost_quadratically` guards the ratio and fails at c125f69b. The run loop also asks LISTED as "no period", as the single-token test does, and `flip_reports` is set once after the run decision rather than piecemeal. diff --git a/docs/design/decisions/cjk-comma-demotion.md b/docs/design/decisions/cjk-comma-demotion.md index c93f9257..892ceb7c 100644 --- a/docs/design/decisions/cjk-comma-demotion.md +++ b/docs/design/decisions/cjk-comma-demotion.md @@ -7,7 +7,7 @@ Closes #469, and continues the corpus-tier arc below rather than standing apart - 2026-09-01 #469 — SCOPES the FAMILY_COMMA bullet under 3-0-reevaluations below, which reads "inherited from v1's lastname-comma but correct on its own terms — an explicit comma is stronger evidence than script". It stands unchanged for LATIN script, which is the script it was argued about. For CJK script it is narrowed to a description: an explicit comma still beats script and the parser still reads it that way, but every input carrying one is a form no East Asian writing system produces, so what the parser makes of it is current behavior and not a promise. The bullet is not edited — it was right about what it was reasoning over, and this entry is the scope it never had to state while Latin was the only script in the room. -- 2026-09-01 #469 — the 2026-08-23 (PR #428) snapshot under decisions/C1 below attributes `田中さん, Dr.` keeping its honorific glued to "(C1 Accepted)". That clause now lives in rules/W3's `tolerated:` note: the BEHAVIOR is unchanged and only its home moved, W3 being where the CJK comma forms are set down now that they are described rather than promised. The snapshot stays as written — it was true when it was taken, and this is the note that says where to read the clause instead. +- 2026-09-01 #469 — the 2026-08-23 (PR #428) snapshot under decisions/C8 (its #428 review entry) attributes `田中さん, Dr.` keeping its honorific glued to "(C1 Accepted)". That clause now lives in rules/W3's `tolerated:` note: the BEHAVIOR is unchanged and only its home moved, W3 being where the CJK comma forms are set down now that they are described rather than promised. The snapshot stays as written — it was true when it was taken, and this is the note that says where to read the clause instead. - CLDR, PULLED VERBATIM 2026-09-01 — #469 asked for the citation and asked that it be a pull rather than a recollection, so the failures are recorded with the findings. The paths named in the plan (`https://raw.githubusercontent.com/unicode-org/cldr/main/common/personNames/ko.xml`, `.../zh.xml`, `.../ja.xml`) all 404: there is no `common/personNames/` directory in the repository, and the data is a `` element inside `common/main/.xml`. Read there instead, from unicode-org/cldr at `main`, commit `8e4fb0fa1e287c4612b512cf2ef77e890467da28`; the grammar from https://www.unicode.org/reports/tr35/tr35-personNames.html. The grammar first, verbatim, because it decides whether absence is evidence: "A namePattern is composed of a sequence of field IDs, each enclosed in curly braces, and separated by zero or more literal characters (eg, space or comma + space)." A comma between surname and given is therefore expressible, and its absence below is a choice the locale data makes rather than a limit of the format. The `order="surnameFirst" usage="referring" formality="formal"` patterns, verbatim, long then medium then short: ko {surname} {given} {given2} {credentials} · {surname} {given} {credentials} · {surname} {given} diff --git a/docs/design/decisions/comma-suffix-arc.md b/docs/design/decisions/comma-suffix-arc.md index 5cb1ed23..8f7532eb 100644 --- a/docs/design/decisions/comma-suffix-arc.md +++ b/docs/design/decisions/comma-suffix-arc.md @@ -1,7 +1,7 @@ # comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01) #291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with -its decline-by-measurement evidence in the issue body; "z domu" folded into it 2026-07-27 (see Excluded, MAIDEN_MARKERS). Intent for #291 and #296 is settled by the approved bundle spec; #296 (with #325) SHIPPED 2026-08-23 as the bundle's first three commits, with one deviation from the audit table recorded at C1 ('md' keeps dual membership — bare 'Md' is Muhammad); #291 was UNSHIPPED then, carried as a deviates: marker on C1 (#291 closed 2026-08-24; the marker came off with #564 in 2.4). The arc's bookkeeping: #291/#296 moved milestone v2.1 → v2.2 on 2026-08-01 (with #289/#293); the 2026-07-30 design doc was amended in place 2026-08-01 (A1–A6); its durable content is HERE rather than in the gitignored doc; #316 (trailing titles) was filed the same day, carrying the esq cleanup as a Related section. +its decline-by-measurement evidence in the issue body; "z domu" folded into it 2026-07-27 (see Excluded, MAIDEN_MARKERS). Intent for #291 and #296 is settled by the approved bundle spec; #296 (with #325) SHIPPED 2026-08-23 as the bundle's first three commits, with one deviation from the audit table recorded at decisions/C8 ('md' keeps dual membership — bare 'Md' is Muhammad); #291 was UNSHIPPED then, carried as a deviates: marker on C1 (#291 closed 2026-08-24; the marker came off with #564 in 2.4). The arc's bookkeeping: #291/#296 moved milestone v2.1 → v2.2 on 2026-08-01 (with #289/#293); the 2026-07-30 design doc was amended in place 2026-08-01 (A1–A6); its durable content is HERE rather than in the gitignored doc; #316 (trailing titles) was filed the same day, carrying the esq cleanup as a Related section. The approved 19-word TITLES∩suffix audit table (2026-07-30/08-01, intent for the unshipped bundle): jr, junior, phd, md, do and se DROP from TITLES (se: Structural Engineer is the PE/SE post-nominal pair, no prenominal convention — v1 residue); dr drops from SUFFIX_WORDS and sra from the suffix sets (v1 residue); sr, lt, cpl, cpt, cpo, csm, sgm, ra and vc KEEP dual membership (position decides); ms and sa keep dual membership AND join the ambiguous set (the periods gate). Derek's framing thesis for the whole table: v1's sets encoded where the v1 PARSER needed words to be, not where words can occur. Amendment A6: once SUFFIX_PHRASES ships, the glued peel's token-level post-nominal test cannot step over a phrase segment ("김민준씨, LEED AP" will not peel) — #291 owns deciding that, with a case row either way. diff --git a/docs/design/decisions/nfd-shapes.md b/docs/design/decisions/nfd-shapes.md index 74831ebf..5998226e 100644 --- a/docs/design/decisions/nfd-shapes.md +++ b/docs/design/decisions/nfd-shapes.md @@ -1,6 +1,6 @@ # nfd-shapes — shape tests read letters, and a decomposed letter is one letter -- 2026-10-12 (#652) — a shape regex spelled a letter `[^\W\d_]`, and `\w` does not match a combining mark, so a name typed in NFD (`inž.` as i, n, z, U+030C) ended its word at the z: `Dr. inž. Janez Novak` made `inž.` the given name, and `Janez Novak, ŽŠČ` reversed because `str.isalpha()` refuses the caps shape. Vocabulary was never affected (`_normalize` NFC-folds); only the shape path was. The bug is as old as 1.4.0, whose `period_abbreviation` had the same class, so this is a fix and not a restoration. DECIDED: a letter in the four letter-shape regexes is `_vocab._LETTER` (letter plus the general combining blocks), `config`'s `period_abbreviation` carries the same class, and the caps predicates NFC-compose a word that is not already NFC before asking `isalpha()`, and the two-letter pair test counts letters rather than code points (`composed_len`; the first version of the fix split `John Smith, MÁ` the other way, found in review). The tests as written (`str.isalpha()`, `unicodedata.is_normalized`) are C-level, so a word already composed pays no frame: `Smith, José` 193 and `Janez Novak, Žiga` 237 frames before and after, default and EVERYWHERE (measured 2026-10-12, py3.11). Output keeps the encoding written; only the reading is made encoding-independent. MEASURED MOVES the release log cannot see, against the released 2.4.0: NFD `Janez Novak, ŽŠČ` went from given `ŽŠČ` (reversed) to suffix `ŽŠČ`, and NFD `Janez Novak, Ž.Š.` went from suffix `Ž.Š.` to given `Ž.Š.`, family `Janez Novak` — the composed twin's reading, which is C1's (paired initials are a name), so the second moves AWAY from the suffix reading and toward parity. Neither is in a corpus. +- 2026-10-12 (#652) — a shape regex spelled a letter `[^\W\d_]`, and `\w` does not match a combining mark, so a name typed in NFD (`inž.` as i, n, z, U+030C) ended its word at the z: `Dr. inž. Janez Novak` made `inž.` the given name, and `Janez Novak, ŽŠČ` reversed because `str.isalpha()` refuses the caps shape. Vocabulary was never affected (`_normalize` NFC-folds); only the shape path was. The bug is as old as 1.4.0, whose `period_abbreviation` had the same class, so this is a fix and not a restoration. DECIDED: a letter in the four letter-shape regexes is `_vocab._LETTER` (letter plus the general combining blocks), `config`'s `period_abbreviation` carries the same class, and the caps predicates NFC-compose a word that is not already NFC before asking `isalpha()`, and the two-letter pair test counts letters rather than code points (`composed_len`; the first version of the fix split `John Smith, MÁ` the other way, found in review). The tests as written (`str.isalpha()`, `unicodedata.is_normalized`) are C-level, so a word already composed pays no frame: `Smith, José` 193 and `Janez Novak, Žiga` 237 frames before and after, default and EVERYWHERE (measured 2026-10-12, py3.11). Output keeps the encoding written; only the reading is made encoding-independent. MEASURED MOVES the release log cannot see, against the released 2.4.0: NFD `Janez Novak, ŽŠČ` went from given `ŽŠČ` (reversed) to suffix `ŽŠČ`, and NFD `Janez Novak, Ž.Š.` went from suffix `Ž.Š.` to given `Ž.Š.`, family `Janez Novak` — the composed twin's reading, which is C5's (paired initials are a name), so the second moves AWAY from the suffix reading and toward parity. Neither is in a corpus. - DECLINED to widen the class to every script's marks: Indic vowel signs and Arabic and Hebrew points are marks too, but rules/H2's Accepted clause already records that an abugida abbreviation never reads as a title by shape, and a general `\p{M}` would change that without a measurement. KNOWN LIMITS, left open (#665): `_INITIAL` / `_render._INITIAL` / `config` `initial` (`\w\.`) are v1-verbatim and public, and an NFD single initial (`É.`) is not initial-shaped there. No role or `initials()` result differs for a lone initial (`Janez Ž. Novak`, 17 shapes × 3 orders), but one does beside a connective: NFC `É. y É. Smith` reads given `É.`, middle `y É.`, NFD reads given `É. y É.`. The same width problem makes the decomposed Cyrillic conjunction `й` (two code points) miss the single-letter tests at `_classify.py` (`len(token.text) == 1`) and `_pieces.py` (`len(text) == 1`): NFD `John й Smith` reads family `John й Smith`. Both predate #652 and read identically on 2.4.0 and master; neither is in a corpus. - 2026-10-13 #665 — DONE, closing both KNOWN LIMITS above. The initial shape is asked of the composed spelling: `_vocab.is_initial_shaped` NFC-composes a non-ASCII word before `_INITIAL`, as `_render` already did for its two copies (#542). Composing was chosen over widening the class because the pattern is the public v1 `initial` regex, pinned verbatim by `test_regex_sync`, and because it gives parity BY CONSTRUCTION — a decomposed word reads as the word it composes to, and a letter-plus-mark with no precomposed form has no composed twin to disagree with, so it is left as the pattern reads it — and likewise the width tests count composed code points, so a caller's one-letter connective with no precomposed form (`q́`, added to `conjunctions`) is two letters to them and joins as a longer connective does (`John q́ Smith` reads given `John q́ Smith`), as rules/P3's "composes to one character" states. The three single-letter width tests (`_classify`'s connective fork and its one-case report, `_pieces`' three-word carve-out) count letters: the classify pair asks `composed_len` only for non-ASCII text that the vocabulary membership has already admitted and the C-level `len(...) == 1` has declined, and `_pieces` composes in C before `isalpha()`. ASCII text pays no frame and the width tests cost composed text nothing (`Dr. Juan Q. Xavier de la Vega III` 366, `Smith, José` 194, `Juan y Eva Garcia` 254, `John й Smith` 255 composed, all equal before and after); the veto below costs one frame per non-ASCII word that reaches it, its matcher being called through a wrapper (`É. y É. Smith` 305 → 307, `Janez Ž. Novak` 237 → 238, `田中. 太郎` 207 → 208; measured 2026-10-13, py3.11, all composed). Kept rather than composing inline at its four call sites: a rare shape no guard counts, against four copies of the one step whose omission this entry is about (AGENTS.md's frame rule, question 4). Decomposed `John й Smith` reads given John, middle й, family Smith, where 2.3.0 through 2.4.1 read the whole name as the given name and reported given-or-family (the KNOWN LIMITS bullet above says family; measured on 2.4.0, 2.4.1 and f9b2b81c it was given, and this bullet corrects it) — a regression of 2.3.0, whose NFC lookup fold (#cjk-full-stops) let the decomposed й match the conjunction vocabulary while the width tests still counted two code points; 1.4.0 through 2.2.0 never matched it and read it as middle й, so this restores their reading. Decomposed `É. y É. Smith` reads given É., middle `y É.`, where every release from 1.4.0 read given `É. y É.`, so that half is a fix. Both checked on the 1.4.0, 2.0.0, 2.2.0, 2.3.0, 2.4.0 and 2.4.1 wheels (2026-10-13). The #320 veto beside the shape is asked of the composed spelling too (`_vocab.in_initialless_script`, ASCII answering before the matcher): decomposed hangul is conjoining jamo, which no `_NO_INITIALS` range holds, so composing the shape alone made a decomposed `씨.` an initial — `김민준, 씨.` read title `씨.` — which the first version of this fix did, caught by `test_suffix_lookup_reads_every_full_stop`. The same veto read on jamo had long let a decomposed hangul word with a period take the leading-abbreviation shape (#H2): `김. 민준` read title `김.` decomposed in every release from 1.4.0 (checked on the 1.4.0, 2.0.0, 2.2.0, 2.3.0 and 2.4.0 wheels), and reads family `김.`, given `민준` as composed now; rules/H2's veto clause states it and carries the decomposed example. An UNSPACED decomposed hangul name is still not split (docs/usage.rst's documented exception), so `김민준. 이서연` keeps differing by encoding. The guard is `tests/v2/test_parser.py`'s twin test, which now compares ambiguity kinds and `initials()` as well as roles; its docstring records which rows each of the four sites holds. diff --git a/docs/design/decisions/phd-merge.md b/docs/design/decisions/phd-merge.md index 4b4d6d07..22beac86 100644 --- a/docs/design/decisions/phd-merge.md +++ b/docs/design/decisions/phd-merge.md @@ -7,6 +7,6 @@ OPENING THE NAME IS NOT OPENING A PIECE LIST, and the first draft got that wrong: a credential run legitimately opens segment 1 after a family comma (`Smith, Ph. D. Jr.`), which C1 reads as a listing rather than a name beginning. Testing `k == 0` alone broke fifteen tests and took the gate red at all three baselines. The caller passes the distinction in, since `_group_segment` cannot see which segment it holds. ACCEPTED, and found by review rather than by design: a TITLE before the pair keeps the credential reading, because `k` is a piece index and a title is a piece. `Sir Ph. D. Van Johnson` still reads given 'Van Johnson' with an empty family — #371's own symptom, surviving one word to the left. It is 1.4.0 parity, and the rule states it as a boundary rather than deviating: "the first piece of the name" cannot be computed before this merge, since H2's abbreviation test is true of `Ph.` itself, so a scan that stepped over titles would step over the very piece being judged. rules/S2 therefore says OPENS THE STRING, which is what the code holds and what v1 held. THE FIRST DRAFT GOT "THE HEAD" WRONG IN THE OTHER DIRECTION, and the corpus could not see it. Testing the PIECE index alone declined the merge for `"Bob" Ph. D. John Smith`: `extract_delimited` removes a quoted or bracketed clause before grouping, so the pair reaches the merge at `k == 0` with a word standing before it in the INPUT. v1 merges there (its regex needed only a preceding space), and 112 measured names broke parity — every one opening with a quote or a bracket, and not one of them in any corpus. The test is now `a[0] == 0`, the first TOKEN, which is v1's boundary exactly. - ACCEPTED: `Parser.revise(suffix="Ph. D.")` now renders 'Ph., D.'. revise() runs a full sub-parse of the string it is given, and a field value has no head for a head-position rule to consult; a second draft carved that out by requiring a name to displace, and the carve-out made the head reading depend on what FOLLOWED it — appending a maiden clause changed whether 'Ph.' was a title, which `test_a_maiden_clause_changes_nothing_else` caught. Dropping the carve-out removed the inconsistency with it. The merge exists for a credential someone TYPED after a name; a caller who writes the spaced form into the suffix field is taken at their word (Derek's call, 2026-08-31). SUPERSEDED 2026-09-06 by #511, and half of the reasoning here still holds: the MERGE is a head-position rule and still does not fire in a field value. The other half — a caller who writes the spaced form is taken at their word — is what #511 reverses (Derek's call, 2026-09-06, choosing the entry rule over the accepted split when the round-trip limit was measured). What changed is that rules/R1's entry pass now runs inside `revise()` over the forced roles, and `Ph.` and `D.` share a comma bucket with nothing between them, so the ENTRY pass joins the pair for the reason it joins `MD` and `PhD` — a different rule reaching the same two words. `Parser.revise(suffix="Ph. D.")` renders 'Ph. D.' (decisions/C1, 2026-09-06 #511). + ACCEPTED: `Parser.revise(suffix="Ph. D.")` now renders 'Ph., D.'. revise() runs a full sub-parse of the string it is given, and a field value has no head for a head-position rule to consult; a second draft carved that out by requiring a name to displace, and the carve-out made the head reading depend on what FOLLOWED it — appending a maiden clause changed whether 'Ph.' was a title, which `test_a_maiden_clause_changes_nothing_else` caught. Dropping the carve-out removed the inconsistency with it. The merge exists for a credential someone TYPED after a name; a caller who writes the spaced form into the suffix field is taken at their word (Derek's call, 2026-08-31). SUPERSEDED 2026-09-06 by #511, and half of the reasoning here still holds: the MERGE is a head-position rule and still does not fire in a field value. The other half — a caller who writes the spaced form is taken at their word — is what #511 reverses (Derek's call, 2026-09-06, choosing the entry rule over the accepted split when the round-trip limit was measured). What changed is that rules/R1's entry pass now runs inside `revise()` over the forced roles, and `Ph.` and `D.` share a comma bucket with nothing between them, so the ENTRY pass joins the pair for the reason it joins `MD` and `PhD` — a different rule reaching the same two words. `Parser.revise(suffix="Ph. D.")` renders 'Ph. D.' (decisions/R1, 2026-09-06 #511). What the leading `Ph.` becomes is H2's business, not this rule's: an abbreviation before a name is almost always a title, which is the same clause that reads `Esq. van Gogh` as title 'Esq.' — so the pair reads title 'Ph.', given 'D.'. The issue proposed given 'Ph.', middle 'D.'; H2 claims the first word before the positional read sees it, and the two answers differ only in which field holds `Ph.`. diff --git a/docs/design/decisions/v1-xfail-triage.md b/docs/design/decisions/v1-xfail-triage.md index a3a147f4..0b7077a3 100644 --- a/docs/design/decisions/v1-xfail-triage.md +++ b/docs/design/decisions/v1-xfail-triage.md @@ -5,7 +5,7 @@ The reconciled v1-style banks (`tests/test_*.py`) carried eight `@pytest.mark.xf uv run python -c "from nameparser import HumanName; [print(repr(s), repr(HumanName(s))) for s in ('Maier, Amy I, Jr.', 'Dr King Jr', 'King, Dr Jr', 'Ahmad ben Husain', \"The Right Hon. the President of the Queen's Bench Division\")]" - 2026-09-01 (Derek's triage) — NOT FIXED, four. The aspiration is rejected, the marker is gone, and the test now pins what the parser does with the grounds recorded beside it so nobody re-derives the rejected proposal: - - `Maier, Amy I, Jr.` — v1 wanted suffix "I, Jr."; it reads given Amy, middle I, family Maier, suffix "Jr.". A middle initial `I` is far more common than an ordinal I borne without a Sr./Jr.-style companion, so where an explicit suffix comma has already named the suffix, the trailing I stays a middle initial. That is #vocabulary-collisions' commonality reasoning applied to a SHAPE rather than to a word — the criterion is written per-word, and the extension is named here rather than smuggled in. **This amends rules/C1 in the same diff.** C1's one-character clause said a bare single letter behind a name word is the generation, full stop, which predicts exactly the suffix "I, Jr." being rejected here. The discriminator the parser has always applied and C1 omitted is the FURTHER COMMA: `Maier, Amy I` reads suffix 'I' while `Maier, Amy I, Jr.` reads middle 'I', and `Smith, John V` against `Smith, John V, Jr.` is the same pair on a name C1 already exemplifies — all four measured. So the rule was incomplete, not the behavior: a rules.md defect rather than a parser one, which is the call this file already made for `'Donald mc'` in the fields-only arc entry (2026-08-27, #451). The qualifier is normative prose carrying no example line of its own; C1's Accepted block records why and names the bank pair as its executable witness. + - `Maier, Amy I, Jr.` — v1 wanted suffix "I, Jr."; it reads given Amy, middle I, family Maier, suffix "Jr.". A middle initial `I` is far more common than an ordinal I borne without a Sr./Jr.-style companion, so where an explicit suffix comma has already named the suffix, the trailing I stays a middle initial. That is #vocabulary-collisions' commonality reasoning applied to a SHAPE rather than to a word — the criterion is written per-word, and the extension is named here rather than smuggled in. **This amends rules/C1 in the same diff.** C1's one-character clause said a bare single letter behind a name word is the generation, full stop, which predicts exactly the suffix "I, Jr." being rejected here. The discriminator the parser has always applied and C1 omitted is the FURTHER COMMA: `Maier, Amy I` reads suffix 'I' while `Maier, Amy I, Jr.` reads middle 'I', and `Smith, John V` against `Smith, John V, Jr.` is the same pair on a name C1 already exemplifies — all four measured. So the rule was incomplete, not the behavior: a rules.md defect rather than a parser one, which is the call this file already made for `'Donald mc'` in the fields-only arc entry (2026-08-27, #451). The qualifier is normative prose carrying no example line of its own; C1's Accepted block (rules/C9's since #728) records why and names the bank pair as its executable witness. - `Dr King Jr` — **FIXED 2026-09-08 (the #316/#489 bundle), and this is the one bullet of the four that moved out of NOT FIXED.** v1 wanted title 'Dr', family 'King', suffix 'Jr', and that is what the parse gives; the leading title peel now leaves a name word a suffix cannot be (#H3). The bullet is amended in place rather than moved, because the half it DECIDED is untouched and is still the reason the shape exists at all. What follows is the 2026-09-01 record, with the reading it describes corrected where the fix falsified it. It read title 'Dr King', family 'Jr' then. `king` stays in the titles vocabulary: it is there for the addressing forms ("King Charles"), and taking it out to serve the surname reading trades a common use for a rarer one, which is the direction #vocabulary-collisions cuts. TITLES has no ambiguous subset and no AmbiguityKind, so — as with MAIDEN_MARKERS and `roz` — the only two expressions of the criterion available here are ship and do not ship; #348 is the open work that would give this set a third answer. The comma format is the road to the surname reading, and is now pinned alongside rather than left as prose — `King, Dr Jr` reads title 'Dr', family 'King', suffix 'Jr'. The test cites [#27](https://github.com/derek73/python-nameparser/issues/27), which is closed; this is the decision it never got. Two halves, and only one was decided. DECIDED: `king` stays in TITLES, which is unchanged and is what still puts `King` inside the title run in the first place. RECORDED, not endorsed: what becomes of the leftover `Jr`. rules/S2 predicts suffix 'Jr' with an empty family — its Accepted clause consumes an unambiguous suffix even when nothing is left to be the family (`Smith Jr.` → family "") — but once the title chain had taken `Dr King`, H1 claimed the one remaining word and it read family 'Jr', suffix ''. `Dr Smith Jr` isolated the cause: family 'Smith', suffix 'Jr', exactly as S2 states. S2 carried a descriptive note saying so. This entry then said that a future change moving `Dr King Jr` toward S2's prediction is an IMPROVEMENT and updates the pin — that change is #H3's peel floor and it landed 2026-09-08. The reading is now title 'Dr', family 'King', suffix 'Jr': the run gives back its last word where everything behind it is post-nominal and that word is not itself suffix vocabulary, so `Dr King Jr` reads like `Dr Smith Jr` rather than against it, and S2's note is replaced by the argument it asked for. The pin in `tests/test_suffixes.py::test_king` moved with it, by decision rather than by drift, and `king` being title vocabulary now buys the row a `title-or-name` report (#H4) rather than a different reading. - `Ahmad ben Husain` — v1 wanted family "ben Husain"; it reads given Ahmad, middle ben, family Husain. Already decided in v0.2.5, when `ben` came out of the prefixes, and for the reason that still holds: `ben` collides with the given name Ben, in the position the particle claim would act on — `Ahmad Ben Husain` reads middle 'Ben' today, which is exactly the token a forward-joining particle claim would take. That is C-i's position test, and it keeps `ben` out. Recorded a second time as a standing keep-out in the particle-line Excluded block in decisions/vocabulary-exclusions, because that is where a wordlist sweep meets it: a keep-out that lives only in a triage entry is one the next Arabic/Hebrew patronymic-particle sweep never reads. Worth naming as a failure mode of its own — the marker was an aspiration that outlived its own resolution, and nothing in a bare xfail says which of the eight were like that. diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 4f3c21f0..06bf3b93 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -29,7 +29,7 @@ Problem shape. A rule needs to know how words were JOINED (chained titles, parti ## UNIT-PARTITION — count the units the joining rules built -Problem shape. A rule counts "one name word", but the input holds words that another rule has already joined into one name — and the joining structure it would read has been merged away. Contract statement. Three rules build multi-word units: a particle chain (P2), a conjunction join (P3), and a bound given-name pair (P5). A rule that counts name words counts those units, and takes each whole or not at all — with one stated exception, rules/C1's count of the words before a comma, which counts particle surnames alone (decisions/C1, #575). How it works. group builds each join as a piece, but its own prefix chain then merges the joined piece into a longer one, so PIECES no longer carries the boundary — the units are rebuilt from the tags the vocabulary layer left (`particle`, `conjunction`, `vocab:bound-given`). The rebuild is RECURSIVE: what a conjunction or a bound word joins is the next UNIT, not the next word, and absorbing a single token instead strands a particle severed from the words it chains. Note the two joins arrive here for opposite reasons — the conjunction join was built and then swallowed, while the bound-given join was never built at all (P5 joins only at the first non-title piece), so restoring piece boundaries in group would fix the first and silently split the second. Lives in. nameparser/_pipeline/_vocab.py (`unit_ends`, the walk, shared since #575) and its two readers: _post_rules.py (`_units`, over classify's tags, for P1's fold) and rules/C1's counts before a comma: `_comma._whole_name`, over classify's tags (`surname_unit_facts`) since #613 moved the decision after classify, and `_vocab.name_word_count`, which builds its facts from the vocabulary (`surname_unit_tags`, held to classify's tags by an agreement test) -- a view script_segment needed while it asked a hand copy of C1 before classify ran, until #613's /simplify had it ask `_comma.decide` itself on a classified copy. A fourth walk is S2's read (#614): `_pieces._chain_units` marks each piece by the unit P2's chain WILL put it in -- opening a unit, or a name word the chain joins -- asking the chain's own `chain_run_end`, which `_group`'s chain calls too; a word the reading weighs (`_weighed`) keeps a unit of its own, as a position always counted it. The particles straight behind each other are merged ahead of the read, into the piece the chain would open its run with (`_pieces.merge_particle_runs`, #625), so a particle run is one piece the read takes whole or not at all, and no mark stands for a particle inside it. The trailing peel's words to spare and #602's run start count in those marks, the peel stops at a word the chain joins, and everything else -- the numeral fork's look at the word in front, the H5 chain, the run's title test -- reads the pieces as written. It runs before the chain joins name words to a particle run, which is why it cannot read the merged tags `unit_ends` walks; #614 had rebuilt the chain as a merged COPY instead, whose three defects in one review -- words the reading weighs hidden inside a unit, indices drifting from the pieces' -- were each patched in that copy, and #620 replaced it with the marks (decisions/S2). C1 counts particle chains only, reaching one word as the fold does: a bound pair builds a given name, and whether P3 joins a connective depends on the whole name the count is helping to decide, so neither is one surname before a comma (`_vocab.SURNAME_UNIT_TAGS`, decisions/C1). rules/P1 and rules/C1 are the counting rules, P2/P3/P5 the joining ones. Reach for it when. A rule says "one name word" and the input can contain a join — enumerate the joining rules out of the rules rather than the ones you remember. +Problem shape. A rule counts "one name word", but the input holds words that another rule has already joined into one name — and the joining structure it would read has been merged away. Contract statement. Three rules build multi-word units: a particle chain (P2), a conjunction join (P3), and a bound given-name pair (P5). A rule that counts name words counts those units, and takes each whole or not at all — with one stated exception, rules/C3's count of the words before a comma, which counts particle surnames alone (decisions/C3, #575). How it works. group builds each join as a piece, but its own prefix chain then merges the joined piece into a longer one, so PIECES no longer carries the boundary — the units are rebuilt from the tags the vocabulary layer left (`particle`, `conjunction`, `vocab:bound-given`). The rebuild is RECURSIVE: what a conjunction or a bound word joins is the next UNIT, not the next word, and absorbing a single token instead strands a particle severed from the words it chains. Note the two joins arrive here for opposite reasons — the conjunction join was built and then swallowed, while the bound-given join was never built at all (P5 joins only at the first non-title piece), so restoring piece boundaries in group would fix the first and silently split the second. Lives in. nameparser/_pipeline/_vocab.py (`unit_ends`, the walk, shared since #575) and its two readers: _post_rules.py (`_units`, over classify's tags, for P1's fold) and rules/C3's counts before a comma: `_comma._whole_name`, over classify's tags (`surname_unit_facts`) since #613 moved the decision after classify, and `_vocab.name_word_count`, which builds its facts from the vocabulary (`surname_unit_tags`, held to classify's tags by an agreement test) -- a view script_segment needed while it asked a hand copy of C1 before classify ran, until #613's /simplify had it ask `_comma.decide` itself on a classified copy. A fourth walk is S2's read (#614): `_pieces._chain_units` marks each piece by the unit P2's chain WILL put it in -- opening a unit, or a name word the chain joins -- asking the chain's own `chain_run_end`, which `_group`'s chain calls too; a word the reading weighs (`_weighed`) keeps a unit of its own, as a position always counted it. The particles straight behind each other are merged ahead of the read, into the piece the chain would open its run with (`_pieces.merge_particle_runs`, #625), so a particle run is one piece the read takes whole or not at all, and no mark stands for a particle inside it. The trailing peel's words to spare and #602's run start count in those marks, the peel stops at a word the chain joins, and everything else -- the numeral fork's look at the word in front, the H5 chain, the run's title test -- reads the pieces as written. It runs before the chain joins name words to a particle run, which is why it cannot read the merged tags `unit_ends` walks; #614 had rebuilt the chain as a merged COPY instead, whose three defects in one review -- words the reading weighs hidden inside a unit, indices drifting from the pieces' -- were each patched in that copy, and #620 replaced it with the marks (decisions/S2). C3 counts particle chains only, reaching one word as the fold does: a bound pair builds a given name, and whether P3 joins a connective depends on the whole name the count is helping to decide, so neither is one surname before a comma (`_vocab.SURNAME_UNIT_TAGS`, decisions/C3). rules/P1 and rules/C3 are the counting rules, P2/P3/P5 the joining ones. Reach for it when. A rule says "one name word" and the input can contain a join — enumerate the joining rules out of the rules rather than the ones you remember. ## MARK-DONT-STRIP — record the decision, keep the fact @@ -47,7 +47,7 @@ How it works. The two layers compose without ordering bugs because the positiona ## READ-WITHOUT-THEN-BIND — read the name without the construct, then fix that reading -Problem shape. An optional construct sits inside a name — a maiden clause, a trailing title — and the requirement is that writing it changes nothing else about the name. Checked after the fact, that invariant has to be defended at every place the construct can end, and each defense has to model what later stages would do to the words it gives up. Contract statement. Read the name as if the construct were not written, using the rule that reads that position, not the whole parse. The construct takes only what that reading leaves. Where later stages could still move what the reading decided, give those words their roles at the take so no later join can reach them. The invariant then holds by construction and is not a property each stop must defend. How it works. M2 before #601 scanned forward, stopped at suffix-looking words, and kept a release check asking at each stop whether the name left standing would read what was given up as post-nominals. That check modelled the P2 chain, the P5 bound join and P6's attachment, and #548 was it failing at the one stop that never asked. #601 replaced the stops and the check with the clause-free reading plus the bind: a net 689 lines out of `_group.py` (+210/−899 in the #601 commit), and #535's grids went from 15,586 and 8,713 invariant violations to 0 (prototype measurement, 2026-10-03, #601's comments). The first half already appears twice. H5's trailing title is TRANSPARENT: "what stands once the chain is taken reads exactly as it would read written without the title, plus the title". P3 asks its word count and its one-case question of the name's OWN words, so a clause beside the name changes neither. M2 is the first to bind as well, because its construct sits in front of the run it reads and the joins come later. C1 is the second (#613): the part after the comma is read once, after classify and before any join, by the reading a part holding no name word gets, and a postnominal part's roles are set there. segment had decided the structure before the tags existed, so it modelled group's particle chain (#562's pair) and assign's reading of a run the capitals settle, and assign re-decided #296's positional read with a second count; all three went. S2 is the third (#614): the trailing run of a main segment is read once, between P3's connective joins and the particle chain, and split off, so the joins that had re-asked the peel before and after themselves -- the chain's stop and its rollback, the bound-given reserve's two views -- answer from that one reading, and assign places the run off it. Where the read sits is the lesson. Joins the run does not depend on but whose units it counts come FIRST: read before P3's connectives, the H5 chain took the second title of a bare `Mr. and Mrs.` and left 'and' as the family name, and a joined pair counted as two words (#614's review). Joins the run does depend on come after, and the read counts what they will build instead -- the particle chain's run and the words it joins as one unit, marked on the pieces rather than built, with every word the reading weighs still a word of its own: #614 counted through a merged copy, whose review found it hiding a shape-only roman numeral and a title inside a credential run, each patched there, until #620 counted the chain's units over the pieces as written (decisions/S2). Two limits, both measured on #601. Read with the TRAILING rule, not the full parse: a head the full parse reads differently (H1's lone title, a P5 join) would otherwise move where the construct ends. And binding means the run can read differently from the same words with no construct (`JANE Q. DOE GEB. LE DO DO` gives suffix `DO DO`, while the full parse of `JANE Q. DOE DO DO` chains them into the family), which rules/M2 accepts in so many words. Lives in. nameparser/_pipeline/_group.py (`_maiden_take`: the view, `tail_reading` over it, and the role-tagged pieces `group()` applies); nameparser/_pipeline/_pieces.py (`tail_reading`, H5's chain); the P3 own-word test in `_group.py`; nameparser/_pipeline/_comma.py (C1's read-and-bind); `_pieces.read_trailing_run` and `_chain_units`, and the read after P3's joins in `_group._group_segment` (S2's). Reach for it when. A rule is growing stops, exceptions or a release check whose job is to keep one construct from disturbing the rest of the name, and above all when that check has started to model another stage. Ask what the name reads as without the construct, and make that the rule. +Problem shape. An optional construct sits inside a name — a maiden clause, a trailing title — and the requirement is that writing it changes nothing else about the name. Checked after the fact, that invariant has to be defended at every place the construct can end, and each defense has to model what later stages would do to the words it gives up. Contract statement. Read the name as if the construct were not written, using the rule that reads that position, not the whole parse. The construct takes only what that reading leaves. Where later stages could still move what the reading decided, give those words their roles at the take so no later join can reach them. The invariant then holds by construction and is not a property each stop must defend. How it works. M2 before #601 scanned forward, stopped at suffix-looking words, and kept a release check asking at each stop whether the name left standing would read what was given up as post-nominals. That check modelled the P2 chain, the P5 bound join and P6's attachment, and #548 was it failing at the one stop that never asked. #601 replaced the stops and the check with the clause-free reading plus the bind: a net 689 lines out of `_group.py` (+210/−899 in the #601 commit), and #535's grids went from 15,586 and 8,713 invariant violations to 0 (prototype measurement, 2026-10-03, #601's comments). The first half already appears twice. H5's trailing title is TRANSPARENT: "what stands once the chain is taken reads exactly as it would read written without the title, plus the title". P3 asks its word count and its one-case question of the name's OWN words, so a clause beside the name changes neither. M2 is the first to bind as well, because its construct sits in front of the run it reads and the joins come later. C1 is the second (#613, rules/C7): the part after the comma is read once, after classify and before any join, by the reading a part holding no name word gets, and a postnominal part's roles are set there. segment had decided the structure before the tags existed, so it modelled group's particle chain (#562's pair) and assign's reading of a run the capitals settle, and assign re-decided #296's positional read with a second count; all three went. S2 is the third (#614): the trailing run of a main segment is read once, between P3's connective joins and the particle chain, and split off, so the joins that had re-asked the peel before and after themselves -- the chain's stop and its rollback, the bound-given reserve's two views -- answer from that one reading, and assign places the run off it. Where the read sits is the lesson. Joins the run does not depend on but whose units it counts come FIRST: read before P3's connectives, the H5 chain took the second title of a bare `Mr. and Mrs.` and left 'and' as the family name, and a joined pair counted as two words (#614's review). Joins the run does depend on come after, and the read counts what they will build instead -- the particle chain's run and the words it joins as one unit, marked on the pieces rather than built, with every word the reading weighs still a word of its own: #614 counted through a merged copy, whose review found it hiding a shape-only roman numeral and a title inside a credential run, each patched there, until #620 counted the chain's units over the pieces as written (decisions/S2). Two limits, both measured on #601. Read with the TRAILING rule, not the full parse: a head the full parse reads differently (H1's lone title, a P5 join) would otherwise move where the construct ends. And binding means the run can read differently from the same words with no construct (`JANE Q. DOE GEB. LE DO DO` gives suffix `DO DO`, while the full parse of `JANE Q. DOE DO DO` chains them into the family), which rules/M2 accepts in so many words. Lives in. nameparser/_pipeline/_group.py (`_maiden_take`: the view, `tail_reading` over it, and the role-tagged pieces `group()` applies); nameparser/_pipeline/_pieces.py (`tail_reading`, H5's chain); the P3 own-word test in `_group.py`; nameparser/_pipeline/_comma.py (C1's read-and-bind); `_pieces.read_trailing_run` and `_chain_units`, and the read after P3's joins in `_group._group_segment` (S2's). Reach for it when. A rule is growing stops, exceptions or a release check whose job is to keep one construct from disturbing the rest of the name, and above all when that check has started to model another stage. Ask what the name reads as without the construct, and make that the rule. ## LICENSED-BY-NEIGHBOR — a structural word is structural only beside the right neighbor @@ -63,7 +63,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 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 in_initialless_script, the #322/#323 instance and the only one here that is a REPERTOIRE test rather than a vocabulary one — the script half of the #320 initial veto, read by is_initial one function away and by _pieces.is_leading_title, so "a script with no initials has no period abbreviations either" is one predicate over _policy._NO_INITIALS rather than a second reading of that table; it lost its leading underscore when the second caller arrived; and caps_shape_candidate, the #516 instance and the newest, called from the sites that each needed the identical question answered — classify's own tag emission and the comma decision's caps test (_segment.py's two comma tests until #613 moved the decision into _comma.py and classify's comma-run branch went with it), with its unit tests — where the usual reason for keeping such copies apart (a shared call costing every default-policy parse a frame it cannot use) does not hold: every caller asks first, in C, what the predicate would decline anyway — the setting and position, then alphabetic capitals and no listed suffix word — so only an unlisted all-caps word reaches the call, at the default as under any setting (decisions/S9)) and nameparser/_pipeline/_pieces.py over pieces: is_suffix_piece, leading_titles and peel_walk are called by both stages, as is is_title_piece since #602 and #603 gave assign two calls of its own (re-measured 2026-10-11), while is_leading_title 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 stops at, and M2's walk wherever no trailing rule reads the clause (elsewhere, since #535, the walk stops where `tail_reading` says) — and segment_suffix_reading, by assign alone from #436/#437 and by `_comma.decide` alone since #613 moved the decision and its binding there, 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/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. `trailing_titles` was that last shape for one day (2026-09-08, the #316/#489 bundle, rules/H5), and since the /simplify round of 2026-09-09 the SHARED predicate is `tail_reading` instead — the peel-and-chain fixed point that answers where the name pieces end (decisions/H5). Assign calls it at its main walk and group's bound-given reserve calls it twice, once per view the join compares, because that reserve reads the name words assign will leave and this walk is half of what leaves them (rules/P5; counting a trailing title word among them joined 'Prof. abdul rahman Prof.' where 'Prof. abdul rahman' does not). Since #535 group's maiden walk calls it as well, over the clause and over the view its take would leave, wherever a trailing rule reads the clause (rules/M2), for the same reason: the walk's stops must end the clause where assign's reading of the name will begin. `peel_trailing` and `trailing_titles` are what that fixed point is BUILT from, and neither is a two-stage question any longer: `peel_trailing` has no caller outside `_pieces.py`: `trailing_start` and `tail_reading`, its two callers in the leaf, fold the numeral's half and the acronym's half into one index. It had one until #601 — the maiden walk in `_group.py`, which asked the bare peel itself one half at a time (#424, #533) and from #535 only where no trailing rule read the clause; since #601 the maiden take reads `tail_reading` and `trailing_candidates` instead (re-measured 2026-10-11 by AST call-site census). `trailing_titles` has exactly one caller outside the leaf, assign's family-comma segment-1 walk, which reads the chain without the re-peel, and `_group.py` does not import it. The tail reading is in the leaf rather than inline because each assign site had been given a cheap frame-free gate written to match the walk's own first condition, which is a second implementation of the question and was removed in review; what the leaf costs is one frame per entry point, measured, and the walk's own first test is a compiled regex rather than a call, so an ordinary name pays a match and stops. The reserve's two calls cost the reference name nothing — it never enters that branch, having no bound given word — and the parse and facade frame counts did not move (measured 2026-09-09). Re-measured 2026-09-09 by an AST call-site census over `_pipeline/*.py` — every call node whose callee is one of these names, keyed by module and enclosing function, which is what caught the census claiming a share for `peel_trailing` that the round had just taken away — the rest of it holds unchanged: is_suffix_piece, leading_titles, peel_walk and now tail_reading shared, is_leading_title, is_title_piece and trailing_start group-only (is_title_piece has since joined the shared list, above) — assign still reads `leading_titles` and never `is_leading_title`, which is what keeps H2's shape inference out of the trailing slot. And nameparser/_pipeline/_post_rules.py over a state: suffix_entries, the #511 instance, the R1 entry pass as a function, the one instance living in a stage rather than in a leaf — it is a pass over a whole ParseState and no leaf takes one, and AGENTS.md names it as the exception — run by post_rules last in the stage (through its in-place worker) and by Parser.revise over a sub-parse whose roles it has forced, so a suffix value handed to revise() derives its entries by the rule a whole name uses rather than by a second reading of the value's commas (decisions/C1, 2026-09-06 #511). Two more since #610 (2026-10-04), both in `_pieces.py`: `particle_tail`, P6's walk, asked by post_rules' attachment and by assign's given-part credential run, which had been two walks agreeing only through the run's roles -- and since #613 asked once per parse, by assign's walk -- which both leaves the tail out of the run and attaches it -- or, behind a comma with nothing before it, by post_rules after H1 and M4; and `trailing_candidates`, the maiden take's choice of clause words, written beside the peel whose admissions it must cover, with a sweep test against `tail_reading` that found two shapes the hand copy had missed (decisions/M2). And `join_connectives` since #617, P3's connective joins, which `_comma.decide` had copied as `_title_chains` and which had already parted from group's three ways (no run merge, an unconditional single-letter skip, the title kind alone): one loop now, taking from each caller what genuinely differs between them -- the carve-out's answer, a titles-only mode, the frozen set. What the fold taught is the last of those: a parameter one caller had been filling with a guard is a question the other caller must answer too. The comma part had never been asked it -- its copy joined a generational connective of more letters as it joined any other (`og`, under caller vocabulary), and never met the default one-letter `i` at all -- and the shared run merge brought `i` into reach, where the property grid caught it (decisions/P3, 2026-10-06 #617). 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 in_initialless_script, the #322/#323 instance and the only one here that is a REPERTOIRE test rather than a vocabulary one — the script half of the #320 initial veto, read by is_initial one function away and by _pieces.is_leading_title, so "a script with no initials has no period abbreviations either" is one predicate over _policy._NO_INITIALS rather than a second reading of that table; it lost its leading underscore when the second caller arrived; and caps_shape_candidate, the #516 instance and the newest, called from the sites that each needed the identical question answered — classify's own tag emission and the comma decision's caps test (_segment.py's two comma tests until #613 moved the decision into _comma.py and classify's comma-run branch went with it), with its unit tests — where the usual reason for keeping such copies apart (a shared call costing every default-policy parse a frame it cannot use) does not hold: every caller asks first, in C, what the predicate would decline anyway — the setting and position, then alphabetic capitals and no listed suffix word — so only an unlisted all-caps word reaches the call, at the default as under any setting (decisions/S9)) and nameparser/_pipeline/_pieces.py over pieces: is_suffix_piece, leading_titles and peel_walk are called by both stages, as is is_title_piece since #602 and #603 gave assign two calls of its own (re-measured 2026-10-11), while is_leading_title 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 stops at, and M2's walk wherever no trailing rule reads the clause (elsewhere, since #535, the walk stops where `tail_reading` says) — and segment_suffix_reading, by assign alone from #436/#437 and by `_comma.decide` alone since #613 moved the decision and its binding there, 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/R1, 2026-09-06); it stays where it is, one call site being no reason to move a predicate that two sites will contest again. `trailing_titles` was that last shape for one day (2026-09-08, the #316/#489 bundle, rules/H5), and since the /simplify round of 2026-09-09 the SHARED predicate is `tail_reading` instead — the peel-and-chain fixed point that answers where the name pieces end (decisions/H5). Assign calls it at its main walk and group's bound-given reserve calls it twice, once per view the join compares, because that reserve reads the name words assign will leave and this walk is half of what leaves them (rules/P5; counting a trailing title word among them joined 'Prof. abdul rahman Prof.' where 'Prof. abdul rahman' does not). Since #535 group's maiden walk calls it as well, over the clause and over the view its take would leave, wherever a trailing rule reads the clause (rules/M2), for the same reason: the walk's stops must end the clause where assign's reading of the name will begin. `peel_trailing` and `trailing_titles` are what that fixed point is BUILT from, and neither is a two-stage question any longer: `peel_trailing` has no caller outside `_pieces.py`: `trailing_start` and `tail_reading`, its two callers in the leaf, fold the numeral's half and the acronym's half into one index. It had one until #601 — the maiden walk in `_group.py`, which asked the bare peel itself one half at a time (#424, #533) and from #535 only where no trailing rule read the clause; since #601 the maiden take reads `tail_reading` and `trailing_candidates` instead (re-measured 2026-10-11 by AST call-site census). `trailing_titles` has exactly one caller outside the leaf, assign's family-comma segment-1 walk, which reads the chain without the re-peel, and `_group.py` does not import it. The tail reading is in the leaf rather than inline because each assign site had been given a cheap frame-free gate written to match the walk's own first condition, which is a second implementation of the question and was removed in review; what the leaf costs is one frame per entry point, measured, and the walk's own first test is a compiled regex rather than a call, so an ordinary name pays a match and stops. The reserve's two calls cost the reference name nothing — it never enters that branch, having no bound given word — and the parse and facade frame counts did not move (measured 2026-09-09). Re-measured 2026-09-09 by an AST call-site census over `_pipeline/*.py` — every call node whose callee is one of these names, keyed by module and enclosing function, which is what caught the census claiming a share for `peel_trailing` that the round had just taken away — the rest of it holds unchanged: is_suffix_piece, leading_titles, peel_walk and now tail_reading shared, is_leading_title, is_title_piece and trailing_start group-only (is_title_piece has since joined the shared list, above) — assign still reads `leading_titles` and never `is_leading_title`, which is what keeps H2's shape inference out of the trailing slot. And nameparser/_pipeline/_post_rules.py over a state: suffix_entries, the #511 instance, the R1 entry pass as a function, the one instance living in a stage rather than in a leaf — it is a pass over a whole ParseState and no leaf takes one, and AGENTS.md names it as the exception — run by post_rules last in the stage (through its in-place worker) and by Parser.revise over a sub-parse whose roles it has forced, so a suffix value handed to revise() derives its entries by the rule a whole name uses rather than by a second reading of the value's commas (decisions/R1, 2026-09-06 #511). Two more since #610 (2026-10-04), both in `_pieces.py`: `particle_tail`, P6's walk, asked by post_rules' attachment and by assign's given-part credential run, which had been two walks agreeing only through the run's roles -- and since #613 asked once per parse, by assign's walk -- which both leaves the tail out of the run and attaches it -- or, behind a comma with nothing before it, by post_rules after H1 and M4; and `trailing_candidates`, the maiden take's choice of clause words, written beside the peel whose admissions it must cover, with a sweep test against `tail_reading` that found two shapes the hand copy had missed (decisions/M2). And `join_connectives` since #617, P3's connective joins, which `_comma.decide` had copied as `_title_chains` and which had already parted from group's three ways (no run merge, an unconditional single-letter skip, the title kind alone): one loop now, taking from each caller what genuinely differs between them -- the carve-out's answer, a titles-only mode, the frozen set. What the fold taught is the last of those: a parameter one caller had been filling with a guard is a question the other caller must answer too. The comma part had never been asked it -- its copy joined a generational connective of more letters as it joined any other (`og`, under caller vocabulary), and never met the default one-letter `i` at all -- and the shared run merge brought `i` into reach, where the property grid caught it (decisions/P3, 2026-10-06 #617). 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/C1-suffix-comma-decision.md b/docs/design/rules/C1-suffix-comma-decision.md index 62aef943..45f6914e 100644 --- a/docs/design/rules/C1-suffix-comma-decision.md +++ b/docs/design/rules/C1-suffix-comma-decision.md @@ -8,188 +8,21 @@ C1. Rationale: a credential run after the comma means the name is in than one word precedes the comma; otherwise it reads as the listing form, the part before the comma being the family name, less a title run opening it (H6), unless the part after it fixes - no family boundary (below). Only - the part after the first comma decides. - Wherever this rule counts the words before the comma, a particle - run and the one name word it attaches to are one word, the reach - P1's fold counts rather than the whole of P2's chain: the listing - form puts a surname before the comma, and a particle surname is - one surname ('De La Cruz, Ed' reads as 'Royce, Ed' does, 'van der - Berg, PhD' as 'Berg, PhD'). Where that surname is the whole part, - the comma settles P1's fork for a leading particle that could be - a given name, being the evidence that the part is a surname; a - word after the one the particle attaches to is a second word, and - a suffix word ends the particle's reach. A word that is also - title vocabulary is not a particle to this count where it opens - the part, reading there as the title it also is (H1); inside a - surname it chains ('de St Pierre, Ed' reads given 'Ed'). A - connective join and a bound given-name pair are not one word - here: whether P3 joins a connective depends on the words of the - whole name, which this count is part of deciding, and a bound - given name builds a given name rather than a surname (P5) — a - word that is a particle as well counts as the particle ('Abu - Bakar, Ed' reads given 'Ed'). A title in front is a word to the - count an unambiguous credential takes, so 'Dr. van der Berg, PhD' - reads as its part does alone. - For the ambiguous credential class — a bare acronym the - vocabulary marks as also an ordinary name, and a word admitted - to the class by shape, which S3 defines and bounds — the - count before the comma is of NAME words rather than of words: - two or more of them read the part after the comma as the - credential run, whatever case the name is written in. The count - goes FIRST and written case is asked only after it: where two - name words stand before the comma the part after it is the - credential run however that part is written, and only where the - count leaves the word a name — one name word before the comma — - is the case read, capitals in a mixed-case name making it the - credential there too (S6). Paired initials are the exception to - the count: two single letters joined by a period, with or without - a period after the second ('M.J.', 'M.J'), the one dotted shape a - person's own initials are written in, read as the given name - after the comma, because two words before the comma may be one - surname written in two ('García Márquez') and the count cannot - tell them from a given name and a family name. Only an - unambiguous suffix word in front of them that is not also title - vocabulary, or another word the class admits by its dotted shape - standing in the same part, makes them the credential run. A word - of this class in front of them speaks for nothing, as S7's - company has it, and where nothing but words of both the suffix - and the title vocabulary stands in front of them, those are the - titles of the given part the initials open, as S7 reads them - there. Behind two or more name words, paired initials that are - not the only shape word in the part are a credential however - little else speaks for them, since no one writes a person's - initials as two dotted groups. An unlisted all-caps word joins - the class in such a part only where the name carries the - contrast: one of its own words before the comma written the way a - name is written in mixed case, holding a capital with its last - cased letter lowercase — read after composing the word, passing - over every non-letter and caseless letter and any lowercase letter - whose capital is not a single letter, as ß's is not — with no - period and not claimed by the vocabulary - as a title, particle, connective, credential or generation. A - surname written in capitals ends in a capital whatever is glued in - front of it, and a word written wholly in lowercase holds none, so - such a record keeps its given name beside its lowercase particles, - titles and clauses ("GISCARD d'ESTAING, VALÉRY", 'LLOYD - FitzGERALD, RONALD'), and a name written wholly in lowercase reads - the listing form. Where the name carries it, an - unlisted word of two capitals after the comma is the paired - initials' shape undotted and is decided at this comma exactly as - they are, by the sentences above ('García Márquez, MJ' and 'García - Márquez, MJ PhD' keep given 'MJ'). The same count reads a part of two or - more words as the credential run when every word of it is a - suffix word or a word of this class, at least one of them of this - class, and none of them a single-letter roman numeral, in any - case: one letter is the shape a middle initial is written in, - and S3 retires single-character matches for the same reason, - while a multi-letter numeral or generational word stands in a - run like any suffix word ('John Smith, III Ma'). A word's - particle reading does not take it out of the run: a word of both - the particle and the unambiguous suffix vocabulary is a suffix - word there too ('John Smith, vd Ma'). Run membership asks what - the word is, and S7's company asks whether it speaks for the word - behind it, which a particle never does; the run S7 says such a - word ends is the company's, not this count's. A word of both the - title and the suffix vocabulary opening a part the count reads as - a run counts as a suffix word there, the name before the comma - being complete, except as the titles of paired initials, above. - Where every word - of this class in the part is a LISTED word written in capitals in - a mixed-case name, the writing has already made each of them the - credential (S6), and the part reads as the credential run on that - evidence rather than on the count; a word of the class by shape - alone carries no such lean, so a part holding one is read by the - count. A part read as postnominal — the credential run, titles - beside suffixes, or titles alone — is read as its words stand, - before any join, and - that reading is final: the one join made inside it is a - connective binding title words into one title (P3: 'Mr. and - Mrs.', a connective run included: 'Minister of the Interior'), - and no particle run, connective join or bound given name - reaches into it. The listing form's given part keeps every join - (P2, P3, P5, P6). - A decision either way at this comma - is reported; for a run of words the decision is the flip to the - credential run, reported once over the whole part. A flip in which - no listed word of this class takes part is the exception and is - made in silence, since only paired initials among such words are - ever taken for a name, and the flip reaches them only where - something else in the part has said otherwise — unless what said - so is nothing but other paired initials: each of those is a word - a reader takes for a name, and that flip is reported. Paired - initials the comma keeps as the given name report where they open - the part; behind a title they do not, the comma's report reaching - only the first word after it ('Smith, Ms G.J.'). A run the - count leaves in the listing form reports as S7 reads the words - in it: a word of this class read as the credential because a - credential in front speaks for it reports, and one its own - capitals made the credential does not unless it opens the part, - where it is the first word after the comma and reports as that - word always does ('Doe, MA PhD', 'John Smith, MA MA'). So a run - whose every such word is written in capitals reads whole in - silence only behind another credential ('John Smith, PhD MA', - 'Smith, PhD MA'), as does a part read as titles before a lone - given name ('Smith, Ms MD Ma'). It is one - of TWO places the comma's own decision is reported, the other being the word trailing the given part after - it (S5), which is a second decision about a second word and never - the same fork twice; an attachment decided after a family comma - (P6) reports on its own. C2's comma-structure flag reports what the - parse could not recognize, not a fork it called. + no family boundary (C8). Only + the part after the first comma decides. The rules after this one + say how the decision reads what it reads: how the words before + the comma are counted (C3); the ambiguous credential class, + counted by name words (C4), with paired initials (C5) and an + unlisted all-caps word (C6) read there by rules of their own; a + part read as postnominal, read before any join (C7); the listing + form's part after the comma, still read for what it is (C8); a + one-character suffix word inside a comma part (C9); a declared + delimiter, which parts a trailing suffix part as a comma would + (C10); and what the decision reports (C11). By default a recognized suffix word counts even written like an initial ("V."), while strict mode vetoes a word read as an initial — in a name written in one case a - bare lower-case letter as well, as its capital is (T4). In the listing form the part after the - comma is still read for what it is: a part that is nothing but - suffix words is the credential run and reads as suffixes, whole - — the slot after a family comma is postnominal position, so the - vocabulary's verdict comes before any title reading of the same - word — and a part that holds no name word at all, titles and suffixes - only, fixes no family boundary, so a part before the comma with - more than one name word keeps its positional read, order and - all. A name word in the part after the comma makes it the - given name, with titles before it and suffixes after, unless a - credential opens the part. A credential opening the part makes it - the postnominal part however it goes on: where the first suffix - word of the part, with only titles in front of it, is one that - starts S8's run, which S8 bounds (the split 'Ph. D.' among them), - every word after it reads as that run reads, a title word as a - title and any other word as a suffix, under strict mode too, - whose veto refuses a word the suffix reading and not the run - ('Smith, PSM I.'). The comma has then fixed nothing, so a part - before it holding two or more name words keeps its positional - read, and a part holding one is the family ('John Smith, PhD - Jones', 'Doe, PhD Jones'). A word of both the title and the - suffix vocabulary opens nothing, and nor does a credential behind - one: in front of a given name it is the listing form's title - ('Smith, Ms Jane'), and no count of the words before the comma - tells a surname of two words from a given name and a family name. - 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 is describing that suffix and continues the - run, written with a period or without, an initial being no shape - anyone writes there; behind a name word it is the generation - only when written bare, a period marking it the abbreviation of - a name and so a middle initial. Both branches then ask that the - generation slot still be open: where a further comma has already - named the suffix, a single letter ending the GIVEN part has no - generation left to be, and stays a middle initial — with a - period or without, and whether a name word or another suffix - word stands before it. Only the given part is touched; a part - after the comma that is nothing but suffix words is the - credential run, and a letter in it continues that run up to the - further comma. Longer suffix words are not in question either - way, and the strict knob above still vetoes the initial-shaped - ones, so the run ends at them there unless a credential opened - the part, above. - A delimiter the policy declares parts a trailing suffix part as a - comma would: the words on each side of it read exactly as the - parts of the same text written with a comma in its place, so no - join, maiden clause or connective reaches across it, and the - delimiter itself is dropped, unless it stands alone as the whole - of its part. Only a trailing part is parted: in the part before - the first comma, or in the given part of the listing form, the - delimiter is a word (the Accepted entry below). + bare lower-case letter as well, as its capital is (T4). "Smith, John" → family="Smith" "سلمان، محمد" → family="سلمان" "田中、太郎" → family="" @@ -198,119 +31,7 @@ C1. Rationale: a credential run after the comma means the name is in "John Smith, V." strict-comma-suffixes → family="John Smith" "john smith, v" strict-comma-suffixes → family="john smith" "Smith, PhD" → family="Smith" · boundary - "Smith, PhD" → suffix="PhD" - "Smith, Jr." → suffix="Jr." - "Smith, Sr." → suffix="Sr." - "Smith, PSM I" → suffix="PSM I" - "Smith, PSM I." → suffix="PSM I." - "Smith, PSM I." strict-comma-suffixes → suffix="PSM I." - "Smith, John V." → middle="V." - "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" - "Doe, MA PhD" → ambiguities=("suffix-or-name",) - "Smith, Dr." → title="Dr." - "Smith, Dr. Jr." → suffix="Jr." - "John Smith, PhD Jones" → given="John" - "John Smith, PhD Jones" → suffix="PhD Jones" - "John Smith, PhD Jones" → ambiguities=("suffix-or-name",) - "Doe, PhD Jones" → suffix="PhD Jones" - "John Smith, Ph. D. Jones" → suffix="Ph. D. Jones" - "John Smith, MD Jones" → given="Jones" · boundary - "Smith, Ms Jane" → title="Ms" · boundary - "John Smith, Mr." → given="John" - "John Smith, Mr." → family="Smith" - "John Smith, Mr. Jr." → given="John" - "Smith Jr., Mr." → family="Smith" · boundary "John Smith, Jones" → family="John Smith" - "John Smith, MA" → suffix="MA" - "John Smith, Ma" → suffix="Ma" - "Smith, MA" → suffix="MA" - "Smith, Ma" → given="Ma" - "John Smith, Ed" → suffix="Ed" - "Davis Royce, Ed" → suffix="Ed" - "Royce, Ed" → given="Ed" · boundary - "Smith Jr., MA" → suffix="Jr., MA" - "Smith Jr., Ma" → given="Ma" · boundary - "John Smith, PhD MEng" → suffix="PhD MEng" - "John Smith, Ed Ma" → suffix="Ed Ma" - "Jane Doe, MS LAc" → suffix="MS LAc" - "Smith, PhD MEng" → family="Smith" · boundary - "John Smith, PhD DO DO" → suffix="PhD DO DO" - "John Smith, PhD DO DO" → ambiguities=() - "John Smith, Mr. and Mrs." → given="John" - "John Smith, Mr. and Mrs." → title="Mr. and Mrs." - "Smith, Chancellor of the Exchequer MP" → suffix="MP" - "Ortega y Gasset, Dr." → given="Ortega" - "John Smith, PhD vd DO" → suffix="PhD vd DO" - "John Smith, vd Ma" → suffix="vd Ma" - "JOHN SMITH, VD MA" → suffix="VD MA" - "JOHN SMITH, VD MA" → ambiguities=("suffix-or-name",) - "John Smith, Jr vd" → ambiguities=() - "John Smith, Jr do" → ambiguities=("suffix-or-name",) - "John Smith, X.Y.Z." → suffix="X.Y.Z." - "John Smith, X.Y.Z." → ambiguities=() - "John Smith, X.Y.Z." unlisted_dotted_suffixes-off → given="X.Y.Z." - "John Smith, XYZ" → suffix="XYZ" - "John Smith, XYZ" → ambiguities=("suffix-or-name",) - "John Smith, XYZ" unlisted_caps_suffixes-off → given="XYZ" - "John Smith, LEED AP" → suffix="LEED AP" - "Smith, XYZ" → given="XYZ" · boundary - "García Márquez, MJ" → given="MJ" · boundary - "MÜLLER WEIß, HANS" → given="HANS" · boundary - "García Márquez, MJ PhD" → given="MJ" · boundary - "García Márquez, MJ JK" → suffix="MJ JK" - "John Smith, PhD XYZ" → suffix="PhD XYZ" - "Smith, A.B." → given="A.B." · boundary - "García Márquez, G.J." → given="G.J." - "García Márquez, G.J." → family="García Márquez" - "John Smith, A.B." → given="A.B." - "John Smith, A.B." → ambiguities=("suffix-or-name",) - "John Smith, X.Y. P.Q." → suffix="X.Y. P.Q." - "De La Cruz, M.J. K.L." → given="M.J." - "De La Cruz, M.J. K.L." → ambiguities=("suffix-or-name", "suffix-or-name") - "De La Cruz, Ed" → given="Ed" - "van der Berg, MA" → family="van der Berg" - "van der Berg, MA" → suffix="MA" - "Van Buren, Ed" → family="Van Buren" - "van der Berg, PhD" → family="van der Berg" - "John van Buren, Ed" → suffix="Ed" · boundary - "Ortega y Gasset, Ed" → suffix="Ed" · boundary - "abdul Salam, Ed" → suffix="Ed" · boundary - "John Smith, PhD X.Y." → suffix="PhD X.Y." - "García Márquez, G.J" → given="G.J" - "De La Cruz, M.J. PhD" → given="M.J." · boundary - "García Márquez, Ms G.J." → given="G.J." · boundary - "García Márquez, Ed G.J." → given="Ed" · boundary - "John Smith, A.B. Ph.D." → given="A.B." · boundary - "Smith, John, Puig - i Soler" extra_suffix_delimiters-dash → suffix="Puig, i Soler" - "Smith, John, PhD - and MD" extra_suffix_delimiters-dash → suffix="PhD, and MD" - Accepted: a title in front of a particle surname, before the - credential class's word, reads the listing form. The surname - counts once and the title is no name word, so the count reads the - listing form, and the listing form peels a title before the comma - (H6) unless the word is a particle too, as 'Freiherr' is, which - stays in the family. For a German rank that reading is the right one: - since 1919 a former noble title is part of the legal surname, - written between the given name and the particle, so 'Freiherr von - Berg' is the family name in 'Freiherr von Berg, Ed', as 2.0 - through 2.3 read it. Before an unambiguous credential the same - words keep the title apart ('Freiherr von Berg, PhD'), that count - taking the title as a word; both are readings of one name. - "Freiherr von Berg, Ed" → family="Freiherr von Berg" - Accepted: three or more initials run together with periods behind - a surname of two words read as a credential. Initials are - conventionally written apart ('J. R. R.'), and a token of three or - more single letters run together is far likelier a credential than - a given name and a middle name written as one word. - "García Márquez, G.J.R." → suffix="G.J.R." - Accepted: a word of both the title and the unambiguous suffix - vocabulary reads as the postnominal after a family comma in - every spelling, the honorific's too — position decides for the - duals, and the slot is postnominal. - "Smith, Ms." → suffix="Ms." - "Smith, Ms. Jane" → title="Ms." Accepted: the vocabulary question above — is the part after the comma nothing but suffix words — is asked a second time, without the word-count condition, to decide whether a glued East Asian @@ -321,34 +42,4 @@ C1. Rationale: a credential run after the comma means the name is in here. It is also the one consequence of this question Latin script cannot witness, nothing there being glued to the end of a name — which is why this clause carries no example of its own. - Accepted: a maiden marker in a part a declared delimiter - separates takes no clause, as no marker in a part after a suffix - comma does, the marker being an ordinary word there (M2). - Releases 2.0 through 2.3 read past the delimiter and took the - clause; the comma reading is accepted as the cost of one rule - for a declared separator. - "Smith, John, MD - née Jones Smith" extra_suffix_delimiters-dash → suffix="MD, née Jones Smith" - Accepted: a delimiter core the policy names (T1) is a word here, - not structure — v1 applied the delimiter to the suffix-comma - form alone, and that limitation is kept as parity: the core in - 'Smith, RN - CRNA' is a word under the policy as without it. - Since #603 it is a word of the run the credential opens, so the - part reads suffix "RN - CRNA" where 2.0 through 2.3, like v1, - read given "RN". - "Smith, RN - CRNA" → suffix="RN - CRNA" - "Steven Hardman, RN - CRNA" → given="Steven" - Accepted: the further-comma qualifier carries no example line of - its own. It discriminates PAIRS and spans both branches, so - exemplifying it means a with-comma partner for each — every one - of them a promise about behavior that has not moved since v1. - Its executable witness is instead the pair - already standing in the v1-style bank, - tests/test_suffixes.py's - test_roman_numeral_i_after_single_initial_lastname_comma_format - and test_roman_numeral_i_with_explicit_suffix_comma_stays_a_middle_initial. - Read the examples above with the qualifier in hand: `Smith, John - V` reads the suffix and `Smith, John PhD I.` continues the run, - while adding a suffix comma after either turns that same letter - into the middle initial. - history: decisions/C1 · interacts: H1, H2, H6, P1, P2, P3, P5, P6, W3, S3, M2, S5, S6, S7, S8, T4 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py - + history: decisions/C1 · interacts: C2, C3, C4, C5, C6, C7, C8, C9, C10, C11, H6, T4, W3 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md b/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md new file mode 100644 index 00000000..245f0d08 --- /dev/null +++ b/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md @@ -0,0 +1,32 @@ +# C10 — a declared delimiter parts a trailing suffix part + +C10. Rationale: a caller who declares a suffix delimiter means it to + separate suffixes as a comma does, and the simplest reading that + honours that is one where it is a comma there. + A delimiter the policy declares parts a trailing suffix part as a + comma would: the words on each side of it read exactly as the + parts of the same text written with a comma in its place, so no + join, maiden clause or connective reaches across it, and the + delimiter itself is dropped, unless it stands alone as the whole + of its part. Only a trailing part is parted: in the part before + the first comma, or in the given part of the listing form, the + delimiter is a word (the Accepted entry below). + "Smith, John, Puig - i Soler" extra_suffix_delimiters-dash → suffix="Puig, i Soler" + "Smith, John, PhD - and MD" extra_suffix_delimiters-dash → suffix="PhD, and MD" + Accepted: a maiden marker in a part a declared delimiter + separates takes no clause, as no marker in a part after a suffix + comma does, the marker being an ordinary word there (M2). + Releases 2.0 through 2.3 read past the delimiter and took the + clause; the comma reading is accepted as the cost of one rule + for a declared separator. + "Smith, John, MD - née Jones Smith" extra_suffix_delimiters-dash → suffix="MD, née Jones Smith" + Accepted: a delimiter core the policy names (T1) is a word here, + not structure — v1 applied the delimiter to the suffix-comma + form alone, and that limitation is kept as parity: the core in + 'Smith, RN - CRNA' is a word under the policy as without it. + Since #603 it is a word of the run the credential opens, so the + part reads suffix "RN - CRNA" where 2.0 through 2.3, like v1, + read given "RN". + "Smith, RN - CRNA" → suffix="RN - CRNA" + "Steven Hardman, RN - CRNA" → given="Steven" · boundary + history: decisions/C10 · interacts: C1, C2, C8, M2, T1 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py diff --git a/docs/design/rules/C11-what-the-comma-decision-reports.md b/docs/design/rules/C11-what-the-comma-decision-reports.md new file mode 100644 index 00000000..a088b243 --- /dev/null +++ b/docs/design/rules/C11-what-the-comma-decision-reports.md @@ -0,0 +1,43 @@ +# C11 — what the comma decision reports + +C11. Rationale: the comma's reading is a call the parse made, and + a reader who could have taken the words the other way is told + that it was made. + A decision either way at the comma C1 reads + is reported; for a run of words the decision is the flip to the + credential run, reported once over the whole part. A flip in which + no listed word of the ambiguous credential class (C4) takes part + is the exception and is + made in silence, since only paired initials among such words are + ever taken for a name, and the flip reaches them only where + something else in the part has said otherwise — unless what said + so is nothing but other paired initials: each of those is a word + a reader takes for a name, and that flip is reported. Paired + initials the comma keeps as the given name (C5) report where they + open the part; behind a title they do not, the comma's report + reaching only the first word after it ('Smith, Ms G.J.'). A run the + count leaves in the listing form reports as S7 reads the words + in it: a word of this class read as the credential because a + credential in front speaks for it reports, and one its own + capitals made the credential does not unless it opens the part, + where it is the first word after the comma and reports as that + word always does ('Doe, MA PhD', 'John Smith, MA MA'). So a run + whose every such word is written in capitals reads whole in + silence only behind another credential ('John Smith, PhD MA', + 'Smith, PhD MA'), as does a part read as titles before a lone + given name ('Smith, Ms MD Ma'). It is one + of TWO places the comma's own decision is reported, the other being the word trailing the given part after + it (S5), which is a second decision about a second word and never + the same fork twice; an attachment decided after a family comma + (P6) reports on its own. C2's comma-structure flag reports what the + parse could not recognize, not a fork it called. + "Doe, MA PhD" → ambiguities=("suffix-or-name",) + "John Smith, PhD DO DO" → ambiguities=() · boundary + "JOHN SMITH, VD MA" → ambiguities=("suffix-or-name",) + "John Smith, Jr vd" → ambiguities=() + "John Smith, Jr do" → ambiguities=("suffix-or-name",) + "John Smith, X.Y.Z." → ambiguities=() + "John Smith, XYZ" → ambiguities=("suffix-or-name",) + "John Smith, A.B." → ambiguities=("suffix-or-name",) + "De La Cruz, M.J. K.L." → ambiguities=("suffix-or-name", "suffix-or-name") + history: decisions/C11 · interacts: A1, C1, C2, C4, C5, P6, S5, S6, S7 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C2-parts-past-the-second-comma.md b/docs/design/rules/C2-parts-past-the-second-comma.md index 6e38c1cb..0980f0e0 100644 --- a/docs/design/rules/C2-parts-past-the-second-comma.md +++ b/docs/design/rules/C2-parts-past-the-second-comma.md @@ -38,7 +38,7 @@ C2. Rationale: text beyond the recognized comma parts should be word behind a particle run there is a title like any other, and the part reports neither a particle chained onto a name word nor an acronym taken into the name. What such a part reports is its - own — C1's report of the decision at its comma (the flip, or the + own — C11's report of the decision at its comma (the flip, or the first word after the comma), S8's and S7's reports on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and this @@ -72,5 +72,5 @@ C2. Rationale: text beyond the recognized comma parts should be reports on outside a tail: the_chain_reports_the_acronym_it_takes and titled_particle_chain_survives_a_title_that_is_also_a_particle. - history: decisions/C1, decisions/S6, decisions/S9 · interacts: C1, H5, P2, P5, S3, S6, S8, S9 · implemented: nameparser/_pipeline/_segment.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py + history: decisions/C7, decisions/C8, decisions/C11, decisions/S6, decisions/S9 · interacts: C1, C7, C8, C11, H5, P2, P5, S3, S6, S8, S9 · implemented: nameparser/_pipeline/_segment.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py diff --git a/docs/design/rules/C3-counting-the-words-before-the-comma.md b/docs/design/rules/C3-counting-the-words-before-the-comma.md new file mode 100644 index 00000000..a7ba1935 --- /dev/null +++ b/docs/design/rules/C3-counting-the-words-before-the-comma.md @@ -0,0 +1,50 @@ +# C3 — counting the words before the comma + +C3. Rationale: the listing form puts a surname before the comma, + and a surname written in several words is one surname. + Wherever the comma's decision (C1) counts the words before the + comma, a particle + run and the one name word it attaches to are one word, the reach + P1's fold counts rather than the whole of P2's chain: the listing + form puts a surname before the comma, and a particle surname is + one surname ('De La Cruz, Ed' reads as 'Royce, Ed' does, 'van der + Berg, PhD' as 'Berg, PhD'). Where that surname is the whole part, + the comma settles P1's fork for a leading particle that could be + a given name, being the evidence that the part is a surname; a + word after the one the particle attaches to is a second word, and + a suffix word ends the particle's reach. A word that is also + title vocabulary is not a particle to this count where it opens + the part, reading there as the title it also is (H1); inside a + surname it chains ('de St Pierre, Ed' reads given 'Ed'). A + connective join and a bound given-name pair are not one word + here: whether P3 joins a connective depends on the words of the + whole name, which this count is part of deciding, and a bound + given name builds a given name rather than a surname (P5) — a + word that is a particle as well counts as the particle ('Abu + Bakar, Ed' reads given 'Ed'). A title in front is a word to the + count an unambiguous credential takes, so 'Dr. van der Berg, PhD' + reads as its part does alone. + "Ortega y Gasset, Dr." → given="Ortega" + "De La Cruz, M.J. K.L." → given="M.J." + "De La Cruz, Ed" → given="Ed" + "van der Berg, MA" → family="van der Berg" + "van der Berg, MA" → suffix="MA" + "Van Buren, Ed" → family="Van Buren" + "van der Berg, PhD" → family="van der Berg" + "John van Buren, Ed" → suffix="Ed" · boundary + "Ortega y Gasset, Ed" → suffix="Ed" · boundary + "abdul Salam, Ed" → suffix="Ed" · boundary + Accepted: a title in front of a particle surname, before the + credential class's word, reads the listing form. The surname + counts once and the title is no name word, so the count reads the + listing form, and the listing form peels a title before the comma + (H6) unless the word is a particle too, as 'Freiherr' is, which + stays in the family. For a German rank that reading is the right one: + since 1919 a former noble title is part of the legal surname, + written between the given name and the particle, so 'Freiherr von + Berg' is the family name in 'Freiherr von Berg, Ed', as 2.0 + through 2.3 read it. Before an unambiguous credential the same + words keep the title apart ('Freiherr von Berg, PhD'), that count + taking the title as a word; both are readings of one name. + "Freiherr von Berg, Ed" → family="Freiherr von Berg" + history: decisions/C3 · interacts: C1, C4, C5, H1, H6, P1, P2, P3, P5 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md b/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md new file mode 100644 index 00000000..9ad51cd4 --- /dev/null +++ b/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md @@ -0,0 +1,63 @@ +# C4 — the ambiguous credential class at the comma + +C4. Rationale: two name words before the comma are a whole name, + and they leave the part after it nothing to be but credentials, + however that part is written. + For the ambiguous credential class — a bare acronym the + vocabulary marks as also an ordinary name, and a word admitted + to the class by shape, which S3 defines and bounds — the + count before the comma is of NAME words rather than of words: + two or more of them read the part after the comma as the + credential run, whatever case the name is written in. The count + goes FIRST and written case is asked only after it: where two + name words stand before the comma the part after it is the + credential run however that part is written, and only where the + count leaves the word a name — one name word before the comma — + is the case read, capitals in a mixed-case name making it the + credential there too (S6). Paired initials are the exception to + the count (C5), and an unlisted all-caps word is a word of this + class here only where the name carries a case contrast (C6). + The same count reads a part of two or + more words as the credential run when every word of it is a + suffix word or a word of this class, at least one of them of this + class, and none of them a single-letter roman numeral, in any + case: one letter is the shape a middle initial is written in, + and S3 retires single-character matches for the same reason, + while a multi-letter numeral or generational word stands in a + run like any suffix word ('John Smith, III Ma'). A word's + particle reading does not take it out of the run: a word of both + the particle and the unambiguous suffix vocabulary is a suffix + word there too ('John Smith, vd Ma'). Run membership asks what + the word is, and S7's company asks whether it speaks for the word + behind it, which a particle never does; the run S7 says such a + word ends is the company's, not this count's. A word of both the + title and the suffix vocabulary opening a part the count reads as + a run counts as a suffix word there, the name before the comma + being complete, except as the titles of paired initials (C5). + Where every word + of this class in the part is a LISTED word written in capitals in + a mixed-case name, the writing has already made each of them the + credential (S6), and the part reads as the credential run on that + evidence rather than on the count; a word of the class by shape + alone carries no such lean, so a part holding one is read by the + count. + "John Smith, MA" → suffix="MA" + "John Smith, Ma" → suffix="Ma" + "Smith, MA" → suffix="MA" + "Smith, Ma" → given="Ma" + "John Smith, Ed" → suffix="Ed" + "Davis Royce, Ed" → suffix="Ed" + "Royce, Ed" → given="Ed" · boundary + "Smith Jr., MA" → suffix="Jr., MA" + "Smith Jr., Ma" → given="Ma" · boundary + "John Smith, PhD MEng" → suffix="PhD MEng" + "John Smith, Ed Ma" → suffix="Ed Ma" + "Jane Doe, MS LAc" → suffix="MS LAc" + "Smith, PhD MEng" → family="Smith" · boundary + "John Smith, PhD DO DO" → suffix="PhD DO DO" + "John Smith, PhD vd DO" → suffix="PhD vd DO" + "John Smith, vd Ma" → suffix="vd Ma" + "JOHN SMITH, VD MA" → suffix="VD MA" + "John Smith, X.Y.Z." → suffix="X.Y.Z." + "John Smith, X.Y.Z." unlisted_dotted_suffixes-off → given="X.Y.Z." + history: decisions/C4 · interacts: C1, C3, C5, C6, C11, S3, S4, S6, S7, S9 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C5-paired-initials-after-the-comma.md b/docs/design/rules/C5-paired-initials-after-the-comma.md new file mode 100644 index 00000000..d7a3d6d8 --- /dev/null +++ b/docs/design/rules/C5-paired-initials-after-the-comma.md @@ -0,0 +1,49 @@ +# C5 — paired initials after the comma + +C5. Rationale: two dotted letters are the shape a person's own + initials are written in, and two words before a comma may be one + surname written in two. + Paired initials are the exception to the ambiguous credential + class's count (C4): two single letters joined by a period, with or without + a period after the second ('M.J.', 'M.J'), the one dotted shape a + person's own initials are written in, read as the given name + after the comma, because two words before the comma may be one + surname written in two ('García Márquez') and the count cannot + tell them from a given name and a family name. Only an + unambiguous suffix word in front of them that is not also title + vocabulary, or another word the class admits by its dotted shape + standing in the same part, makes them the credential run. A word + of this class in front of them speaks for nothing, as S7's + company has it, and where nothing but words of both the suffix + and the title vocabulary stands in front of them, those are the + titles of the given part the initials open, as S7 reads them + there. Behind two or more name words, paired initials that are + not the only shape word in the part are a credential however + little else speaks for them, since no one writes a person's + initials as two dotted groups. Where the name carries the case + contrast C6 asks for, an + unlisted word of two capitals after the comma is the paired + initials' shape undotted and is decided at this comma exactly as + they are ('García Márquez, MJ' and 'García + Márquez, MJ PhD' keep given 'MJ'). + "García Márquez, MJ" → given="MJ" · boundary + "García Márquez, MJ PhD" → given="MJ" · boundary + "García Márquez, MJ JK" → suffix="MJ JK" + "Smith, A.B." → given="A.B." · boundary + "García Márquez, G.J." → given="G.J." + "García Márquez, G.J." → family="García Márquez" + "John Smith, A.B." → given="A.B." + "John Smith, X.Y. P.Q." → suffix="X.Y. P.Q." + "John Smith, PhD X.Y." → suffix="PhD X.Y." + "García Márquez, G.J" → given="G.J" + "De La Cruz, M.J. PhD" → given="M.J." · boundary + "García Márquez, Ms G.J." → given="G.J." · boundary + "García Márquez, Ed G.J." → given="Ed" · boundary + "John Smith, A.B. Ph.D." → given="A.B." · boundary + Accepted: three or more initials run together with periods behind + a surname of two words read as a credential. Initials are + conventionally written apart ('J. R. R.'), and a token of three or + more single letters run together is far likelier a credential than + a given name and a middle name written as one word. + "García Márquez, G.J.R." → suffix="G.J.R." + history: decisions/C5 · interacts: C4, C6, C11, S3, S7 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md b/docs/design/rules/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md new file mode 100644 index 00000000..77fcc4c6 --- /dev/null +++ b/docs/design/rules/C6-unlisted-all-caps-word-needs-the-names-case-contrast.md @@ -0,0 +1,27 @@ +# C6 — an unlisted all-caps word needs the name's case contrast + +C6. Rationale: capitals are a credential's writing only against a + name written otherwise; a name written in capitals, or wholly in + lowercase, says nothing about any word in it. + An unlisted all-caps word joins the ambiguous credential class + (C4, S9) in the part after the comma only where the name carries the + contrast: one of its own words before the comma written the way a + name is written in mixed case, holding a capital with its last + cased letter lowercase — read after composing the word, passing + over every non-letter and caseless letter and any lowercase letter + whose capital is not a single letter, as ß's is not — with no + period and not claimed by the vocabulary + as a title, particle, connective, credential or generation. A + surname written in capitals ends in a capital whatever is glued in + front of it, and a word written wholly in lowercase holds none, so + such a record keeps its given name beside its lowercase particles, + titles and clauses ("GISCARD d'ESTAING, VALÉRY", 'LLOYD + FitzGERALD, RONALD'), and a name written wholly in lowercase reads + the listing form. + "John Smith, XYZ" → suffix="XYZ" + "John Smith, XYZ" unlisted_caps_suffixes-off → given="XYZ" + "John Smith, LEED AP" → suffix="LEED AP" + "Smith, XYZ" → given="XYZ" · boundary + "MÜLLER WEIß, HANS" → given="HANS" · boundary + "John Smith, PhD XYZ" → suffix="PhD XYZ" + history: decisions/C6 · interacts: C4, C5, S6, S9 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md b/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md new file mode 100644 index 00000000..8227eec6 --- /dev/null +++ b/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md @@ -0,0 +1,20 @@ +# C7 — a postnominal part is read before any join + +C7. Rationale: the comma's decision reads the part after it once, + and a join made afterwards could only read the same words + differently from the decision. + A part read as postnominal — the credential run, titles + beside suffixes, or titles alone — is read as its words stand, + before any join, and + that reading is final: the one join made inside it is a + connective binding title words into one title (P3: 'Mr. and + Mrs.', a connective run included: 'Minister of the Interior'), + and no particle run, connective join or bound given name + reaches into it. The listing form's given part keeps every join + (P2, P3, P5, P6). + "John Smith, Mr. and Mrs." → given="John" + "John Smith, Mr. and Mrs." → title="Mr. and Mrs." + "Smith, Chancellor of the Exchequer MP" → suffix="MP" + no-boundary: the listing form's given part, which keeps every + join, is what P2's, P3's and P5's own examples parse. + history: decisions/C7 · interacts: C1, C2, C8, P2, P3, P5, P6 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C8-listing-forms-part-after-the-comma.md b/docs/design/rules/C8-listing-forms-part-after-the-comma.md new file mode 100644 index 00000000..9b58ddb7 --- /dev/null +++ b/docs/design/rules/C8-listing-forms-part-after-the-comma.md @@ -0,0 +1,56 @@ +# C8 — the listing form's part after the comma + +C8. Rationale: a comma fixes a family boundary only where a name + word stands after it to be the given name; a part of titles and + suffixes alone names nobody. + In the listing form the part after the + comma is still read for what it is: a part that is nothing but + suffix words is the credential run and reads as suffixes, whole + — the slot after a family comma is postnominal position, so the + vocabulary's verdict comes before any title reading of the same + word — and a part that holds no name word at all, titles and suffixes + only, fixes no family boundary, so a part before the comma with + more than one name word keeps its positional read, order and + all. A name word in the part after the comma makes it the + given name, with titles before it and suffixes after, unless a + credential opens the part. A credential opening the part makes it + the postnominal part however it goes on: where the first suffix + word of the part, with only titles in front of it, is one that + starts S8's run, which S8 bounds (the split 'Ph. D.' among them), + every word after it reads as that run reads, a title word as a + title and any other word as a suffix, under strict mode too, + whose veto refuses a word the suffix reading and not the run + ('Smith, PSM I.'). The comma has then fixed nothing, so a part + before it holding two or more name words keeps its positional + read, and a part holding one is the family ('John Smith, PhD + Jones', 'Doe, PhD Jones'). A word of both the title and the + suffix vocabulary opens nothing, and nor does a credential behind + one: in front of a given name it is the listing form's title + ('Smith, Ms Jane'), and no count of the words before the comma + tells a surname of two words from a given name and a family name. + "Smith, PhD" → suffix="PhD" + "Smith, Jr." → suffix="Jr." + "Smith, Sr." → suffix="Sr." + "Smith, PSM I." strict-comma-suffixes → suffix="PSM I." + "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." + "Smith, MD PhD" → suffix="MD PhD" + "Smith, Dr." → title="Dr." + "Smith, Dr. Jr." → suffix="Jr." + "John Smith, PhD Jones" → given="John" + "John Smith, PhD Jones" → suffix="PhD Jones" + "John Smith, PhD Jones" → ambiguities=("suffix-or-name",) + "Doe, PhD Jones" → suffix="PhD Jones" + "John Smith, Ph. D. Jones" → suffix="Ph. D. Jones" + "John Smith, MD Jones" → given="Jones" · boundary + "Smith, Ms Jane" → title="Ms" · boundary + "John Smith, Mr." → given="John" + "John Smith, Mr." → family="Smith" + "John Smith, Mr. Jr." → given="John" + "Smith Jr., Mr." → family="Smith" · boundary + Accepted: a word of both the title and the unambiguous suffix + vocabulary reads as the postnominal after a family comma in + every spelling, the honorific's too — position decides for the + duals, and the slot is postnominal. + "Smith, Ms." → suffix="Ms." + "Smith, Ms. Jane" → title="Ms." + history: decisions/C8 · interacts: C1, C7, C9, C11, H1, H6, S8 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C9-one-character-suffix-word-inside-a-comma-part.md b/docs/design/rules/C9-one-character-suffix-word-inside-a-comma-part.md new file mode 100644 index 00000000..76be4a46 --- /dev/null +++ b/docs/design/rules/C9-one-character-suffix-word-inside-a-comma-part.md @@ -0,0 +1,42 @@ +# C9 — a one-character suffix word inside a comma part + +C9. Rationale: one letter is the shape a middle initial is written + in, so only what stands before it says whether it is a + generation. + 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 is describing that suffix and continues the + run, written with a period or without, an initial being no shape + anyone writes there; behind a name word it is the generation + only when written bare, a period marking it the abbreviation of + a name and so a middle initial. Both branches then ask that the + generation slot still be open: where a further comma has already + named the suffix, a single letter ending the GIVEN part has no + generation left to be, and stays a middle initial — with a + period or without, and whether a name word or another suffix + word stands before it. Only the given part is touched; a part + after the comma that is nothing but suffix words is the + credential run, and a letter in it continues that run up to the + further comma. Longer suffix words are not in question either + way, and the strict knob (C1) still vetoes the initial-shaped + ones, so the run ends at them there unless a credential opened + the part (C8). + "Smith, PSM I" → suffix="PSM I" + "Smith, PSM I." → suffix="PSM I." + "Smith, John V." → middle="V." + "Smith, John PhD I." → suffix="PhD I." + "Smith, John V" → suffix="V" · boundary + Accepted: the further-comma qualifier carries no example line of + its own. It discriminates PAIRS and spans both branches, so + exemplifying it means a with-comma partner for each — every one + of them a promise about behavior that has not moved since v1. + Its executable witness is instead the pair + already standing in the v1-style bank, + tests/test_suffixes.py's + test_roman_numeral_i_after_single_initial_lastname_comma_format + and test_roman_numeral_i_with_explicit_suffix_comma_stays_a_middle_initial. + Read the examples above with the qualifier in hand: `Smith, John + V` reads the suffix and `Smith, John PhD I.` continues the run, + while adding a suffix comma after either turns that same letter + into the middle initial. + history: decisions/C9 · interacts: C1, C8, S5, S10, T4 · implemented: nameparser/_pipeline/_assign.py diff --git a/docs/design/rules/H2-leading-abbreviation-title.md b/docs/design/rules/H2-leading-abbreviation-title.md index 9dfeeb5d..7915cc2b 100644 --- a/docs/design/rules/H2-leading-abbreviation-title.md +++ b/docs/design/rules/H2-leading-abbreviation-title.md @@ -12,11 +12,11 @@ H2. Rationale: before a name, an abbreviation is almost always a vocabulary: a period-marked opening word is a title even when the word is suffix vocabulary — except after a family comma, where a part that is nothing but suffix words is the credential - run (C1) and the abbreviation opens nothing. An accented Latin, + run (C8) and the abbreviation opens nothing. An accented Latin, Greek or Cyrillic letter written as a base letter plus a combining mark (NFD) is the same letter as the composed one, for this shape and for the dotted and all-caps credential - shapes C1, S9 and S3 read (#652), so such a name parses the + shapes C5, C6, S9 and S3 read (#652), so such a name parses the same in either encoding and the output keeps the one written. "Rev. John Smith" → title="Rev." "Xyz. John Smith" → title="Xyz." @@ -51,9 +51,9 @@ H2. Rationale: before a name, an abbreviation is almost always a abbreviation opening that part stays in the family. "Xyz. Smith, John" → family="Xyz. Smith" Accepted: after a family comma a part that is nothing but suffix - words is the credential run (C1), which the abbreviation does + words is the credential run (C8), which the abbreviation does not open: the vocabulary decides, and "Esq." is the postnominal it is. "Smith, Esq." → suffix="Esq." - history: decisions/H2 · interacts: C1, S3, P4, H5, H6, W3, W4, S9 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py + history: decisions/H2 · interacts: C5, C6, C8, S3, P4, H5, H6, W3, W4, S9 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py diff --git a/docs/design/rules/H6-title-before-a-family-comma.md b/docs/design/rules/H6-title-before-a-family-comma.md index 98673778..2ce05e2c 100644 --- a/docs/design/rules/H6-title-before-a-family-comma.md +++ b/docs/design/rules/H6-title-before-a-family-comma.md @@ -42,7 +42,7 @@ H6. Rationale: the listing form moves the surname to the front and Accepted: what keeps a word in the family is the particle stop and nothing else. 'Freiherr' and 'St' are particles as well as titles, so they stay in either spelling, keeping 'St Pierre' - whole and 'Freiherr von Berg' as C1 reads it. The other German + whole and 'Freiherr von Berg' as C3 reads it. The other German ranks are titles only, so they peel, though since 1919 a rank is part of the legal surname for all of them alike; that is the vocabulary's distinction, not this rule's. A title in front of @@ -50,5 +50,5 @@ H6. Rationale: the listing form moves the surname to the front and "Graf Lambsdorff, Otto" → title="Graf" "Freiherr von Berg, Ed" → family="Freiherr von Berg" "Dr. Freiherr von Berg, Ed" → title="Dr." - history: decisions/H6 · interacts: C1, H1, H2, H3, M2, P1, P2, P4, S2, W3 · implemented: nameparser/_pipeline/_assign.py + history: decisions/H6 · interacts: C1, C3, H1, H2, H3, M2, P1, P2, P4, S2, W3 · implemented: nameparser/_pipeline/_assign.py diff --git a/docs/design/rules/M2-maiden-marker-rule.md b/docs/design/rules/M2-maiden-marker-rule.md index 6293b4aa..1ad21b75 100644 --- a/docs/design/rules/M2-maiden-marker-rule.md +++ b/docs/design/rules/M2-maiden-marker-rule.md @@ -143,5 +143,5 @@ M2. Rationale: a maiden marker announces that what follows it is the Accepted: a marker behind a credential is an ordinary word, and the credential's run (S8) takes it in with everything after it. "Jane Doe Jr. nee Smith" → suffix="Jr. nee Smith" · boundary - history: decisions/M2 · interacts: P2, P3, P5, P6, R1, R2, M1, S1, S2, H1, H5, C1, S4, S8 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py + history: decisions/M2 · interacts: P2, P3, P5, P6, R1, R2, M1, S1, S2, H1, H5, C1, C10, S4, S8 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/P3-connectives.md b/docs/design/rules/P3-connectives.md index 8d20f112..c1f17423 100644 --- a/docs/design/rules/P3-connectives.md +++ b/docs/design/rules/P3-connectives.md @@ -24,7 +24,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into S10 reads it as a numeral S10's report is the one. The joined part is ONE name word wherever another rule counts them, so a rule taking "one name word" takes the whole join and - never half of it — except C1's count of the words before a comma, + never half of it — except C3's count of the words before a comma, which counts particle surnames alone and states why. A connective counts as a name word wherever this rule counts them, whatever else the vocabulary says the word is, where it is placed @@ -45,7 +45,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into spells wherever S10 reads one there. A separator the caller declared, standing in a trailing suffix part, ends the search there as a comma would, so a connective - never joins across it (C1); anywhere else it is a word. + never joins across it (C10); anywhere else it is a word. Both questions this rule asks of a name — how many words it has, and whether it is written in one case — are asked of the name's OWN words: a maiden marker taken as one, and the words it takes @@ -58,7 +58,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into counts toward the three — but its case is still not asked. That span is not this rule's alone: wherever another rule asks the case question — the suffix slot (S6) and the post-comma slot - (C1) — it is asked of these same words, so the answer is taken + (C4, C6) — it is asked of these same words, so the answer is taken once and read where each of them stands. "Juan y Eva Garcia" → given="Juan y Eva" "Jose E Maria Santos" → middle="E Maria" @@ -153,5 +153,5 @@ P3. Rationale: connective words ("y", "of the") bind name words into same two words unjoined are two name words and H1 does not fire. P1's leading run is the second (#395, landed): its run takes the "Vega y Santos" join whole or stops before it. - history: decisions/P3 · interacts: H1, P1, M2, R1, R3, R4, C1, S6, S10, T4 · implemented: nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py + history: decisions/P3 · interacts: H1, P1, M2, R1, R3, R4, C3, C4, C6, C7, C10, S6, S10, T4 · implemented: nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py diff --git a/docs/design/rules/P6-trailing-orphan-particle.md b/docs/design/rules/P6-trailing-orphan-particle.md index 841637b5..831b6ca9 100644 --- a/docs/design/rules/P6-trailing-orphan-particle.md +++ b/docs/design/rules/P6-trailing-orphan-particle.md @@ -196,5 +196,5 @@ P6. Rationale: a particle ending the name has nothing to link negative-control sweep pinning the disagreeing set the precedence bullet above names. A change that breaks one side of that pair should expect that test, not this file, to say so first. - history: decisions/P6 · interacts: A1, C1, H5, P1, S2, P5, M2, S4, S7 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py + history: decisions/P6 · interacts: A1, C1, C7, C11, H5, P1, S2, P5, M2, S4, S7 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py diff --git a/docs/design/rules/R1-fields-are-views-of-the-parsed-words.md b/docs/design/rules/R1-fields-are-views-of-the-parsed-words.md index 8cac089b..4a7fb245 100644 --- a/docs/design/rules/R1-fields-are-views-of-the-parsed-words.md +++ b/docs/design/rules/R1-fields-are-views-of-the-parsed-words.md @@ -27,7 +27,7 @@ R1. Rationale: a field is a way of reading the parse, not a stored revises back to itself wherever the value's words read as the whole name read them — a glued CJK honorific peels off an initial in a bare value where the whole name kept it glued, one corpus - name of the 372 with a suffix (decisions/C1, 2026-09-06, which + name of the 372 with a suffix (decisions/R1, 2026-09-06, which counted 368 of 1117 then; re-measured 2026-09-10 with this bundle's rows in the corpora, 372 of 1156 distinct names and the same single failure). A delimiter the configuration names parts a @@ -38,5 +38,5 @@ R1. Rationale: a field is a way of reading the parse, not a stored instead. Stated without an example line because every line here names an input string, and this shape needs a field revised after the parse. - history: decisions/C1 · interacts: O3, P3, P6, R3, M2 · implemented: nameparser/_parser.py, nameparser/_pipeline/_post_rules.py, nameparser/_types.py + history: decisions/R1 · interacts: O3, P3, P6, R3, M2 · implemented: nameparser/_parser.py, nameparser/_pipeline/_post_rules.py, nameparser/_types.py diff --git a/docs/design/rules/README.md b/docs/design/rules/README.md index 7723f48d..61d6604f 100644 --- a/docs/design/rules/README.md +++ b/docs/design/rules/README.md @@ -78,6 +78,15 @@ The marker's unit is the whole rule, so a clause that reaches past the tolerated - [C1 — the suffix-comma decision](C1-suffix-comma-decision.md) - [C2 — parts past the second comma](C2-parts-past-the-second-comma.md) +- [C3 — counting the words before the comma](C3-counting-the-words-before-the-comma.md) +- [C4 — the ambiguous credential class at the comma](C4-ambiguous-credential-class-at-the-comma.md) +- [C5 — paired initials after the comma](C5-paired-initials-after-the-comma.md) +- [C6 — an unlisted all-caps word needs the name's case contrast](C6-unlisted-all-caps-word-needs-the-names-case-contrast.md) +- [C7 — a postnominal part is read before any join](C7-postnominal-part-is-read-before-any-join.md) +- [C8 — the listing form's part after the comma](C8-listing-forms-part-after-the-comma.md) +- [C9 — a one-character suffix word inside a comma part](C9-one-character-suffix-word-inside-a-comma-part.md) +- [C10 — a declared delimiter parts a trailing suffix part](C10-declared-delimiter-parts-a-trailing-suffix-part.md) +- [C11 — what the comma decision reports](C11-what-the-comma-decision-reports.md) ## [Name order (O)](O-name-order.md) diff --git a/docs/design/rules/S10-trailing-roman-numeral.md b/docs/design/rules/S10-trailing-roman-numeral.md index 3c6ec800..7ffa6eda 100644 --- a/docs/design/rules/S10-trailing-roman-numeral.md +++ b/docs/design/rules/S10-trailing-roman-numeral.md @@ -64,4 +64,4 @@ S10. Rationale: a roman numeral ending a name is its generation, as one with the numeral in front of it, the run taking both, as 1.4.0 read it. "John Smith V Ph. D." → suffix="V Ph. D." - history: decisions/S10 · interacts: S2, S4, S7, C1, P2, P3, M2, H1, H3, T4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py + history: decisions/S10 · interacts: S2, S4, S7, C1, C9, P2, P3, M2, H1, H3, T4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py diff --git a/docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md b/docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md index da7c5584..42bac9c1 100644 --- a/docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md +++ b/docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md @@ -14,7 +14,7 @@ S2. Rationale: generational suffixes and credentials are recognized says "opening" and does not mean what P4 and the H Background mean by it. A family comma is the other boundary: the comma has already named the family, so a credential run may open the part - after it (C1), and the part before it is that family, less a + after it (C8), and the part before it is that family, less a title run opening it (H6). A trailing word of the suffix vocabulary reads as a suffix — generational forms and credential acronyms alike, and an @@ -87,4 +87,4 @@ S2. Rationale: generational suffixes and credentials are recognized and unchanged (decisions/v1-xfail-triage: `king` stays a title, for the addressing forms). "Dr Jr" → suffix="Jr" - history: decisions/S2 · interacts: H1, H2, H3, H4, H5, H6, C1, S3, S4, S5, S6, S7, S8, S10, P2, P3, P4, P5 · implemented: nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py + history: decisions/S2 · interacts: H1, H2, H3, H4, H5, H6, C1, C8, S3, S4, S5, S6, S7, S8, S10, P2, P3, P4, P5 · implemented: nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/S3-credentials-written-with-interior-periods.md b/docs/design/rules/S3-credentials-written-with-interior-periods.md index 7c7a53db..d7a6454a 100644 --- a/docs/design/rules/S3-credentials-written-with-interior-periods.md +++ b/docs/design/rules/S3-credentials-written-with-interior-periods.md @@ -18,12 +18,12 @@ S3. Rationale: credentials are often written run together with trailing the given part after one, the word ending a maiden marker's clause (M2), and the part before a SUFFIX comma. The part before a FAMILY comma never reports, the comma - having already named it the family. C1 states two exceptions at - the first slot after a comma: paired initials ('M.J.') read as - the given name unless something in the part speaks for them, and - a flip to the credential run in which no listed member of the - class takes part is not reported, unless paired initials speak - only for each other. Case says nothing here — the + having already named it the family. Two exceptions hold at the + first slot after a comma: paired initials ('M.J.') read as the + given name unless something in the part speaks for them (C5), + and a flip to the credential run in which no listed member of + the class takes part is not reported, unless paired initials + speak only for each other (C11). Case says nothing here — the periods are the evidence — and three shapes are outside it: a single trailing period is not this shape at all, a chunk that is not wholly alphabetic is no acronym letter, and a word carrying @@ -57,5 +57,5 @@ S3. Rationale: credentials are often written run together with the CJK assertions in tests/v2/pipeline/test_vocab.py's test_period_joined_vocab_retires_the_single_character_chunk. W3 states the same precedence for the same reason. - history: decisions/S3 · interacts: C1, H2, W3, S4 · implemented: nameparser/_pipeline/_vocab.py + history: decisions/S3 · interacts: C4, C5, C11, H2, W3, S4 · implemented: nameparser/_pipeline/_vocab.py diff --git a/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md b/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md index 5b330c1c..d358b127 100644 --- a/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md +++ b/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md @@ -20,7 +20,7 @@ S4. Rationale: an acronym that is also an ordinary name (MA, Ma) of evidence among several: the given part's trailing slot after a family comma is read without it (S5), written case outranks it in a mixed-case name everywhere but the first slot after a - comma, where the count decides first (S6, C1), and an + comma, where the count decides first (S6, C4), and an unambiguous credential in front outranks both (S7). "John Ma" → family="Ma" · boundary "wang meng" → family="meng" · boundary @@ -28,4 +28,4 @@ S4. Rationale: an acronym that is also an ordinary name (MA, Ma) "john smith meng" → suffix="meng" "JOHN SMITH MA" → suffix="MA" "Nguyen Van Lac" → family="Van Lac" - history: decisions/S4 · interacts: C1, C2, M2, S2, S3, S5, S6, S7, S9 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py + history: decisions/S4 · interacts: C1, C2, C4, M2, S2, S3, S5, S6, S7, S9 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md b/docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md index a67dfecf..83e6e92c 100644 --- a/docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md +++ b/docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md @@ -66,4 +66,4 @@ S5. Rationale: a family comma has already named the family and the "Doe, John van Ma" → ambiguities=() · boundary "Doe, John DO Ed" → middle="DO Ed" · boundary "Doe, John DO Ed" → ambiguities=() · boundary - history: decisions/S5 · interacts: C1, H5, P1, P6, S4, S6, S7 · implemented: nameparser/_pipeline/_assign.py + history: decisions/S5 · interacts: C1, C11, H5, P1, P6, S4, S6, S7 · implemented: nameparser/_pipeline/_assign.py diff --git a/docs/design/rules/S6-case-signal-at-the-suffix-slot.md b/docs/design/rules/S6-case-signal-at-the-suffix-slot.md index a80f8b31..ca4060b8 100644 --- a/docs/design/rules/S6-case-signal-at-the-suffix-slot.md +++ b/docs/design/rules/S6-case-signal-at-the-suffix-slot.md @@ -13,8 +13,8 @@ S6. Rationale: in a name written in more than one case, the any word in it, and the count decides alone; so does a script with no case to write in. After a family comma this evidence is SECOND at the FIRST slot after it: the count of name words - before the comma decides there first (C1), and the case is read - only where that count leaves the word a name. C1 states the one + before the comma decides there first (C4), and the case is read + only where that count leaves the word a name. C4 states the one exception: a part of two or more suffix words and words of this class, every word of the class listed and written in capitals in a mixed-case name, is read on its capitals before the count is @@ -32,4 +32,4 @@ S6. Rationale: in a name written in more than one case, the "john smith MEng" → family="MEng" "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" - history: decisions/S6 · interacts: C1, S4, S5, S7 · implemented: nameparser/_pipeline/_vocab.py + history: decisions/S6 · interacts: C4, C11, S4, S5, S7 · implemented: nameparser/_pipeline/_vocab.py diff --git a/docs/design/rules/S7-company-of-an-unambiguous-credential.md b/docs/design/rules/S7-company-of-an-unambiguous-credential.md index ffad9fa8..6912bf6b 100644 --- a/docs/design/rules/S7-company-of-an-unambiguous-credential.md +++ b/docs/design/rules/S7-company-of-an-unambiguous-credential.md @@ -64,4 +64,4 @@ S7. Rationale: post-nominals are written as a run, and an "Jack Wei Ma" → family="Ma" "Jack Wei Ma" → ambiguities=("suffix-or-name",) "abdul Smith Jr Ma" → suffix="Jr Ma" - history: decisions/S7 · interacts: C1, M2, P2, P3, P6, S4, S5, S6, S8 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py + history: decisions/S7 · interacts: C4, C5, C11, M2, P2, P3, P6, S4, S5, S6, S8 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/S8-credentials-trailing-run.md b/docs/design/rules/S8-credentials-trailing-run.md index cfee3532..7386de3e 100644 --- a/docs/design/rules/S8-credentials-trailing-run.md +++ b/docs/design/rules/S8-credentials-trailing-run.md @@ -45,4 +45,4 @@ S8. Rationale: a credential is the writer's mark that the "राम शर्मा साहब मोहन" → suffix="साहब मोहन" "राम शर्मा साहब मोहन" → ambiguities=("suffix-or-name",) "John PhD Smith" → family="Smith" · boundary - history: decisions/S8 · interacts: H5, M2, P2, P3, P5, P6, S2, S4, S7, C1 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py + history: decisions/S8 · interacts: H5, M2, P2, P3, P5, P6, S2, S4, S7, C8 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md b/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md index 95c331e9..6707dcc9 100644 --- a/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md +++ b/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -11,7 +11,7 @@ S9. Rationale: no wordlist holds every credential, and a word's position of a mixed-case name and belonging to no wordlist, is the other. By default that second shape is admitted in one position only, the part after a comma with two or more name - words before it (C1): French and Korean records write the + words before it (C6): French and Korean records write the SURNAME in capitals, and that convention puts the capitals at the end of a name or before a comma, never after a comma behind a full name. Everywhere else it is admitted only under the @@ -21,14 +21,14 @@ S9. Rationale: no wordlist holds every credential, and a word's family name to the acronym, while a two-word name keeps it — there are no words to spare there, so the class is considered and declined and only the fork is reported. The dotted shape does not take every promise of the - class with it: at the first slot after a comma, C1 reads paired - initials as the given name unless something speaks for them, and - makes a flip to the credential run in which no listed member takes - part in silence, unless paired initials speak only for each - other. + class with it: at the first slot after a comma, the comma's + decision reads paired initials as the given name unless + something speaks for them (C5), and makes a flip to the + credential run in which no listed member takes part in silence, + unless paired initials speak only for each other (C11). "John Smith XYZ" → family="XYZ" · boundary "John Smith XYZ" unlisted_caps_suffixes-everywhere → suffix="XYZ" "Jean DUPONT" unlisted_caps_suffixes-everywhere → family="DUPONT" · boundary "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → suffix="DUPONT" "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → family="Pierre" - history: decisions/S9 · interacts: C1, S3, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py + history: decisions/S9 · interacts: C4, C5, C6, C11, S3, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py diff --git a/docs/release_log.rst b/docs/release_log.rst index 69975190..686e070a 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -140,9 +140,9 @@ 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()`` (:issue:`484`) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions/`` - - **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 (``김민준 박사 씨``) follow the same rule. This is a deliberate deviation from 1.4.0, and 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; 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``. ``str()`` is still a rendering rather than a canonical form. See the ``C1`` entry of ``docs/design/decisions/`` (closes :issue:`436`, closes :issue:`437`) + - **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 (``김민준 박사 씨``) follow the same rule. This is a deliberate deviation from 1.4.0, and 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; 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``. ``str()`` is still a rendering rather than a canonical form. See the ``R1`` entry of ``docs/design/decisions/`` (closes :issue:`436`, closes :issue:`437`) - - **Fix Parser.revise() splitting a space-separated suffix value into comma-separated entries.** ``Parser().revise(n, suffix="MD PhD").suffix`` is ``MD PhD`` and was ``MD, PhD``. A suffix value's entries are now derived from the value's own commas by the rule a whole name uses -- a comma parts two credentials and a space joins them -- so ``revise(n, suffix="MD, PhD")`` is still two entries, and ``revise(n, suffix="Ph. D.")`` renders ``Ph. D.`` where the 2.2.0 note below accepted ``Ph., D.``. One limit: a delimiter configured through ``extra_suffix_delimiters`` parts a value only where the value's own words read as a name with a tail segment, so in a run of post-nominals it stays a word; write a comma at the boundary instead. ``ParsedName.replace()`` is unchanged. See the ``C1`` entry of ``docs/design/decisions/`` (closes :issue:`511`) + - **Fix Parser.revise() splitting a space-separated suffix value into comma-separated entries.** ``Parser().revise(n, suffix="MD PhD").suffix`` is ``MD PhD`` and was ``MD, PhD``. A suffix value's entries are now derived from the value's own commas by the rule a whole name uses -- a comma parts two credentials and a space joins them -- so ``revise(n, suffix="MD, PhD")`` is still two entries, and ``revise(n, suffix="Ph. D.")`` renders ``Ph. D.`` where the 2.2.0 note below accepted ``Ph., D.``. One limit: a delimiter configured through ``extra_suffix_delimiters`` parts a value only where the value's own words read as a name with a tail segment, so in a run of post-nominals it stays a word; write a comma at the boundary instead. ``ParsedName.replace()`` is unchanged. See the ``R1`` entry of ``docs/design/decisions/`` (closes :issue:`511`) - **Remove rai and cha from the default post-nominal acronyms, so a trailing Rai or CHA keeps the family name.** ``HumanName("Aishwarya Rai")`` gives last ``Rai``, where 2.0.0 through 2.2.0 gave suffix ``Rai`` and no last name at all -- 1.4.0's reading, restored. The cost is that a genuine credential written after a full name is no longer recognized: ``John Smith RAI`` gives last ``RAI``, and ``John Smith, RAI`` gives first ``RAI``, last ``John Smith``. A caller who needs either back adds it: ``Lexicon.default().add(suffix_acronyms={"cha"})``. See the ``suffix-acronym-collisions`` entry of ``docs/design/decisions/`` (closes :issue:`342`) diff --git a/nameparser/_parser.py b/nameparser/_parser.py index fadb2b6f..0f9c4b46 100644 --- a/nameparser/_parser.py +++ b/nameparser/_parser.py @@ -220,7 +220,7 @@ def revise(self, name: ParsedName, **fields: str) -> ParsedName: # tags are KEPT, "joined" included: a within-piece mark # (the Ph. D. merge) is role-blind and right for every # role, and a clear was measured and backed out -- - # decisions/C1 (2026-09-06 #511) carries the rest: the + # decisions/R1 (2026-09-06 #511) carries the rest: the # between-piece mark that rides onto a non-suffix role, and # which views read it. Dropped tokens keep their role: the # pass filters them by index and assemble omits them. diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index da2b5841..c2679c7b 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -621,6 +621,9 @@ def _reads_as_a_trailing_suffix(piece: Sequence[int], under decisions/P5 records the same fork declining to transfer to this walk under LENIENT. """ + # rules/C9: "behind a name word it is the generation only when + # written bare, a period marking it the abbreviation of a name and + # so a middle initial" text = tokens[piece[0]].text if text.endswith(".") and not is_suffix_piece( prev_piece, prev_ptags, tokens): @@ -829,11 +832,11 @@ def assign(state: ParseState) -> ParseState: fam_tags = state.piece_tags[0] # The part after the comma reaches here as the given part, or # bound and with no pieces left: group's `_comma.decide` binds a - # postnominal one before any join (#613, rules/C1), so this + # postnominal one before any join (#613, rules/C7), so this # path is the listing form's, and the positional read #296 gave # a comma followed by no name word behind two name words is # decide's suffix comma now, read by the NO_COMMA branch above. - # rules/C1's exception, scoped to the ambiguous credential + # rules/C11's report, scoped to the ambiguous credential # class: this is the given-name half of the comma's OWN # decision (listing or credential run), where the writing left # the fork open. `_comma.decide` reports the credential half, @@ -1209,7 +1212,7 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: # rules/C1: "anything else after the comma means the # listing form" -- and this walk reads it. A segment of no # name word never reaches here: it is `_comma.decide`'s, read piece by piece and bound at - # group's head (#613, rules/C1), the suffix verdict + # group's head (#613, rules/C8), the suffix verdict # before the title reading of the same word (#296, #325). # A name word in the segment makes it v1's walk ('Smith, # John Jr.'). @@ -1520,7 +1523,7 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: # keeps a bare trailing title word from misreading are not there to # protect ('Eric H. Holder, Jr., Attorney General'). A # piece holding a suffix word stays the postnominal the slot makes - # it, a dual alone as after a family comma (C1) and a dual group's + # it, a dual alone as after a family comma (C8) and a dual group's # connective join took into a title ('PhD - and MD') alike. for seg_idx in range(tail, len(state.segments)): for piece, ptags_ in zip(state.pieces[seg_idx], diff --git a/nameparser/_pipeline/_comma.py b/nameparser/_pipeline/_comma.py index a6bfa658..6c50a5d7 100644 --- a/nameparser/_pipeline/_comma.py +++ b/nameparser/_pipeline/_comma.py @@ -3,7 +3,7 @@ The comma's structure is decided ONCE, after classify has tagged every word and before group joins any of them (#613): the part after the first comma is read by `_pieces.segment_suffix_reading`, the one -reading a part that holds no name word gets, with the words C1's count +reading a part that holds no name word gets, with the words C4's count licenses as credentials behind a whole name. Where it reads as the postnominal part its roles are set here, so no join in group and no walk in assign can reach them, and the comma is a suffix comma when @@ -147,13 +147,15 @@ def _name_contrast(state: ParseState, seg0: Sequence[int]) -> bool: def _whole_name(seg0: Sequence[int], tokens: Sequence[WorkToken]) -> bool: - """rules/C1's count before the comma for an unambiguous + """rules/C3's count before the comma for an unambiguous credential, read off classify's tags (the ambiguous class's count of NAME words, which leaves titles out, is `_vocab.name_word_count`'s, asked in `decide`): two or more units (a particle run and the name word it attaches to one unit, a connective join not, #575) holding a word that is not a suffix word -- a title counts, being a word to the count an unambiguous credential takes.""" + # rules/C3: "a particle run and the one name word it attaches to are + # one word" pieces, ptags = _pieces(seg0, tokens) named: set[int] = set() for piece, tags in zip(pieces, ptags): @@ -191,11 +193,11 @@ def decide(state: ParseState) -> ParseState: return state tokens = list(state.tokens) lex, pol = state.lexicon, state.policy - # rules/C1: "A delimiter the policy declares parts a trailing + # rules/C10: "A delimiter the policy declares parts a trailing # suffix part as a comma would" -- behind a whole name its cores are # no word of the reading, and leave as group drops them in a tail; # behind one name word the core is a word, v1 having applied the - # delimiter to the suffix-comma form alone (C1's Accepted entry) + # delimiter to the suffix-comma form alone (C10's Accepted entry) cores = delimiter_cores(pol.extra_suffix_delimiters) whole: bool | None = None core_idx: list[int] = [] @@ -247,8 +249,8 @@ def decide(state: ParseState) -> ParseState: names = name_word_count([tokens[i].text for i in seg0], lex, pol, letters_one_case=state.one_case) if names >= 2: - # rules/C1: "An unlisted all-caps word joins the class in such - # a part only where the name carries the contrast" + # rules/C6: "An unlisted all-caps word joins the ambiguous + # credential class [...] only where the name carries the contrast" contrast: bool | None = None for i in maybe: text = tokens[i].text @@ -268,7 +270,7 @@ def decide(state: ParseState) -> ParseState: # for, a lone one included ('John Smith, XY') if contrast: caps.add(i) - # rules/C1: "The same count reads a part of two or more words + # rules/C4: "The same count reads a part of two or more words # as the credential run when every word of it is a suffix word # or a word of this class", none of them a single-letter numeral. # the words not of the class are asked as C1's suffix test asks @@ -292,7 +294,7 @@ def decide(state: ParseState) -> ParseState: if not run: licensed.clear() else: - # rules/C1: "Paired initials are the exception to the + # rules/C5: "Paired initials are the exception to the [...] # count" -- "Only an unambiguous suffix word in front of # them that is not also title vocabulary, or another word # the class admits by its dotted shape standing in the same @@ -343,9 +345,10 @@ def decide(state: ParseState) -> ParseState: reading = (True,) * len(p1) if reading is None: return state - # The part is the postnominal part, bound here whatever stands - # before the comma. The comma is a suffix comma behind a whole name; - # behind one name word it stays the family comma naming the family. + # rules/C7: "that reading is final" -- the part is the postnominal + # part, bound here whatever stands before the comma. The comma is a + # suffix comma behind a whole name; behind one name word it stays + # the family comma naming the family. ambiguities = list(state.ambiguities) for k, piece in enumerate(p1): role = Role.SUFFIX if reading[k] else Role.TITLE @@ -356,10 +359,10 @@ def decide(state: ParseState) -> ParseState: # EVERYWHERE, so case repair keeps 'XYZ' (rules/R4) tags = tags | {SHAPE_ACRONYM_TAG, AMBIGUOUS_ACRONYM_TAG} tokens[i] = copy_with(tokens[i], role=role, tags=tags) - # rules/C1: "A decision either way at this comma is reported; + # rules/C11: "A decision either way at the comma C1 reads is reported; # for a run of words the decision is the flip to the credential # run, reported once over the whole part. A flip in which no listed - # word of this class takes part is the exception and is made in + # word of the ambiguous credential class (C4) takes part is the exception and is made in # silence" -- "unless what said so is nothing but other paired # initials". A run whose every class word is listed and settled by # its capitals is read on that evidence, not flipped by the count. @@ -418,7 +421,7 @@ def decide(state: ParseState) -> ParseState: AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i].text!r} behind a credential after the comma is " f"also an ordinary name word; read as a credential", (i,))) - # rules/C1: "A credential opening the part makes it the + # rules/C8: "A credential opening the part makes it the # postnominal part however it goes on" -- each word it takes that # would have made it the given part, as #602's run reports them for k in absorbed: diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index 3383bf12..b0284b26 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -1402,7 +1402,7 @@ def group(state: ParseState) -> ParseState: else ClauseSite.FAMILY_PART) else: site = ClauseSite.TRAILING - # rules/C1: "a delimiter the policy declares parts a + # rules/C10: "a delimiter the policy declares parts a # trailing suffix part as a comma would" (#549): the segment is # grouped as the parts between its cores, each read exactly as # the comma twin's own segment would be, and the cores are diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index c659f057..0e9be903 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -762,13 +762,13 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], (mechanisms.md#ONE-PREDICATE-PER-QUESTION); it answered for a third until #436, group's one-entry join. - `licensed` holds the pieces rules/C1's count has licensed as + `licensed` holds the pieces rules/C4's count has licensed as credentials -- ambiguous-class words in a credential run behind two name words -- and each reads as a suffix whatever its writing or company. The initial veto (`_vocab.is_initial`) keeps a roman numeral out - of a suffix reading, which is right after a NAME word (rules/C1's + of a suffix reading, which is right after a NAME word (rules/C9's one-character suffix word, read by what stands before it): 'Smith, John V.' is a middle initial (#432). After a SUFFIX word the numeral is describing that suffix -- 'PSM I' is Professional Scrum Master @@ -777,7 +777,7 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], that: what follows a bare title is not continuing a credential. A part OPENED by a credential is the postnominal part however it - goes on (#603, rules/C1): where an unambiguous suffix word that + goes on (#603, rules/C8): where an unambiguous suffix word that starts #602's run (`starts_a_credential_run`) is the first suffix word of the part, with only titles in front of it (a title/suffix dual among them ends the chance: a credential behind one opens @@ -867,7 +867,7 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], leading = False out.append(True) continue - # A word rules/C1's count licenses as a credential: the + # A word rules/C4's count licenses as a credential: the # caller (`_comma.decide`) counted two name words before the # comma and found the part a credential run, which settles the # ambiguous word here before S7's anchors are asked (#613) diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index d74ef151..edd0982a 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -141,7 +141,7 @@ def _mark_suffix_entries(tokens: list[WorkToken], state: ParseState) -> None: # 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/C1 by the + # Four shapes were declined, recorded in decisions/R1 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 @@ -214,7 +214,7 @@ def suffix_entries(state: ParseState) -> ParseState: role, so a suffix value derives its entries from its own commas by the rule a whole name uses. The pass ADDS the tag and never removes one; a caller forcing roles keeps what the sub-parse marked, a - within-piece mark being role-blind (decisions/C1, the #436 + within-piece mark being role-blind (decisions/R1, the #436 DECLINED list) and every between-piece mark on a suffix value being one this pass sets again. Reads comma_offsets, dropped and Policy.extra_suffix_delimiters; writes the "joined" tag and diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index abd31427..ef1b83cc 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -315,7 +315,7 @@ def reads_as_a_reported_numeral(text: str, preceding: str, # written, and rules/S3 retires single-character matches for the # same reason -- not for being a generation: a multi-letter suffix # word ('III', 'Jr') anchors and runs like any other. So it neither -# anchors an ambiguous member behind it nor counts toward C1's +# anchors an ambiguous member behind it nor counts toward C4's # multi-word credential run. It still peels exactly as before; this # answers only those two questions. def is_single_letter_numeral(text: str) -> bool: @@ -467,7 +467,7 @@ def is_paired_initials(text: str) -> bool: """Two single letters run together with periods ('M.J.'): the one unlisted dotted shape a person's own initials take, so after a comma it is a name until something in front of it, or another - dotted word beside it, says it is a credential (rules/C1). + dotted word beside it, says it is a credential (rules/C5). Three or more letters ('X.Y.Z.') and any longer chunk ('B.Tech.') are outside it.""" return _PAIRED_INITIALS.fullmatch(text) is not None @@ -834,7 +834,7 @@ def claimed_as_non_name(n: str, lexicon: Lexicon) -> bool: # The candidate test for a comma part read before classify -# (rules/C1, decisions/S9): segment's C2 flag on a tail part. +# (rules/C4, decisions/S9): segment's C2 flag on a tail part. def ambiguous_class_candidate(text: str, lexicon: Lexicon, policy: Policy) -> bool: """Whether TEXT is a CANDIDATE for the ambiguous credential class @@ -907,7 +907,7 @@ def ambiguous_class_candidate(text: str, lexicon: Lexicon, # mechanisms.md#UNIT-PARTITION: "A rule that counts name words counts -# those units, and takes each whole or not at all" -- C1's count before +# those units, and takes each whole or not at all" -- C3's count before # a comma being the stated exception (`SURNAME_UNIT_TAGS`). The walk is shared # by readers that hold the facts in different forms: post_rules and # `_comma._whole_name` read classify's TAGS, while `name_word_count` @@ -929,7 +929,7 @@ def unit_ends(tags: Sequence[Set[str]], chain: bool = True) -> list[int]: particle run and every word after it up to the next stop ("van der Berg Smith" is one unit). False is P1's fold reach: the particle run and the ONE unit it attaches to, so "de Mesnil Jean" is two -- - the count rules/C1 needs before a comma, where under a + the count rules/C3 needs before a comma, where under a family-first order that part is family 'de Mesnil', given 'Jean' (#575). @@ -980,7 +980,7 @@ def end_of(i: int) -> int: return ends -#: The facts rules/C1's count before a comma reads (#575): a +#: The facts rules/C3's count before a comma reads (#575): a #: particle starts a surname unit, and a suffix word stops it. A word #: that is ALSO title vocabulary ('Freiherr', 'St') LEADING the part is #: not a particle here: in front of a name it reads as the title it @@ -1048,7 +1048,7 @@ def name_word_count(texts: Sequence[str], lexicon: Lexicon, """How many of these texts are NAME words -- not suffix vocabulary, not title vocabulary. - rules/C1's count for the ambiguous class, and it is of names + rules/C4's count for the ambiguous class, and it is of names rather than of words because 'Smith Jr., MA' is two tokens and one name: counting tokens there flips the structure and hands the family to `given`, which no reading of that string wants diff --git a/nameparser/_types.py b/nameparser/_types.py index 2be39eed..dc9833c2 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -467,7 +467,7 @@ class AmbiguityKind(StrEnum): #: site and this kind stays out of the way -- "Doe, John do" gives #: family ``do Doe`` and one ``PARTICLE_OR_GIVEN``, never two #: reports of one word. - #: Since #544 C1's name-word count reports its flip once over a + #: Since #544 C4's name-word count reports its flip once over a #: whole RUN after the comma ("John Smith, Ed Ma" reads suffix #: ``Ed Ma``), and a member an unambiguous credential in front of #: it speaks for (rules/S7) is a pick like a counted one, diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 2042d518..8cbe0934 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -392,7 +392,7 @@ # title 'MS', and 'Smith, Ms.' passed the gate on its one period # and read as a credential anyway. Both words are genuine duals -- # title and unambiguous suffix -- and position decides, as for - # 'sr' and 'lt' (decisions/C1). + # 'sr' and 'lt' (decisions/C8). # # NOT 'se' or 'om' either, weighed 2026-09-07 with #342 and left # alone: no surname evidence worth standing behind, and OM is the diff --git a/tests/test_suffixes.py b/tests/test_suffixes.py index d21343c7..50b19d10 100644 --- a/tests/test_suffixes.py +++ b/tests/test_suffixes.py @@ -247,9 +247,9 @@ def test_roman_numeral_i_with_explicit_suffix_comma_stays_a_middle_initial(self) # Contrast test_roman_numeral_i_after_single_initial_lastname_comma_format # above, where no explicit suffix comma is present and the 'I' does go to # suffix. - # rules/C1 gained a qualifier with this triage: where a further + # rules/C9 (then part of rules/C1) gained a qualifier with this triage: where a further # comma has already named the suffix, the trailing single letter has - # no generation left to be. C1 carries no example line for it (see the + # no generation left to be. C9 carries no example line for it (see the # Accepted note there), so this pair of tests is its witness. hn = HumanName("Maier, Amy I, Jr.") self.m(hn.first, "Amy", hn) @@ -305,7 +305,7 @@ def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: # Through 2.3 (and in v1) that broke parsing -- first 'RN', last # 'Steven Hardman', suffix 'CRNA' -- and this test pinned the # limitation. Since #603 a credential opening the part after the - # comma makes it the suffix part (rules/C1), so the undeclared + # comma makes it the suffix part (rules/C8), so the undeclared # delimiter is one more word of that part and the name parses; # the name of the test keeps the history. hn = HumanName("Steven Hardman, RN - CRNA") diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 966d7aa0..df5dbfd1 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -383,7 +383,7 @@ def _check_cjk_shape_purity(self) -> None: "#516's by-shape class now reads the SAME single " "pre-comma word as a candidate for the ambiguous class " "-- one word is never enough to flip the structure " - "(rules/C1), so the FIELDS stay at the 1.4.0 " + "(rules/C4), so the FIELDS stay at the 1.4.0 " "reading, but the class is now considered, and the " "consideration reports: the parity note above is about " "the reading, not about whether a fork was called", @@ -515,7 +515,7 @@ def _check_cjk_shape_purity(self) -> None: notes="with 'cha' gone from the vocabulary (#342) the word is " "unlisted, and since #564 an unlisted all-caps word " "after a comma behind two name words is a credential by " - "default (rules/C1, S9), so the comma is a suffix " + "default (rules/C6, S9), so the comma is a suffix " "comma again and reports the call. 2.3.0 read given " "'CHA', family 'Ahmad Jayadi'; 'John Smith, RAI' moves " "with it"), @@ -591,7 +591,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the marking's comma cost, and it is 'Smith, Ed' " "arriving for a second word: with 'ba' ambiguous, S4 " - "declines the post-comma credential and C1 reads it as " + "declines the post-comma credential and C4 reads it as " "the given name. 2.0.0 through 2.2.0 read family " "'Smith', suffix 'BA'; 1.4.0 read given 'BA'. #289 moves " "this row again: the caps lean now reads 'Smith, BA' the " @@ -678,10 +678,10 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the marking's comma cost: it is the same S6 case " "reading the row above pins, arriving after a family " - "comma, where C1 has already settled the word count " + "comma, where C4 has already settled the word count " "and the writing is the only evidence left. Every " "release from 1.4.0 read suffix 'MEng'. 'John Smith, " - "MEng' keeps the suffix on C1's name-word count", + "MEng' keeps the suffix on C4's name-word count", shape=2), Case("comma_lone_meng_reads_as_the_given_name", "Smith, meng", {"given": "meng", "family": "Smith"}, @@ -690,7 +690,7 @@ def _check_cjk_shape_purity(self) -> None: "reading 'Smith, Ma' gets, arriving for another " "member: with the word ambiguous, S4 declines the " "post-comma credential and, with the count leaving it " - "a name, C1 reads it as the given name. 2.0.0 through " + "a name, C4 reads it as the given name. 2.0.0 through " "2.3.0 read " "family 'Smith', suffix 'meng'; 1.4.0 read given " "'meng', so this row restores 1.4.0's reading and " @@ -713,7 +713,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, PhD MEng", {"given": "John", "family": "Smith", "suffix": "PhD MEng"}, ambiguities=("suffix-or-name",), - notes="C1's name-word count reads a RUN as it reads one word " + notes="C4's name-word count reads a RUN as it reads one word " "(#544): every word after the comma is suffix " "vocabulary or a class member, and two name words " "stand before it, so the part is the credential run " @@ -775,7 +775,7 @@ def _check_cjk_shape_purity(self) -> None: "report, and every release read it this way", shape=1), # ---- #544: a credential run keeps its ambiguous members --------- - # C1's name-word count reads a RUN after the comma as it reads one + # C4's name-word count reads a RUN after the comma as it reads one # word, and S7's company clause lets an unambiguous credential IN # FRONT of a member speak for it at every trailing slot. The rows # below are the issue's names and the boundaries each half keeps. @@ -783,7 +783,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, Ed Ma", {"given": "John", "family": "Smith", "suffix": "Ed Ma"}, ambiguities=("suffix-or-name",), - notes="C1's run rule with no unambiguous word in it: two " + notes="C4's run rule with no unambiguous word in it: two " "members, both Title case, and two name words before " "the comma, so the count reads the part as the " "credential run and the flip reports once over the " @@ -856,7 +856,7 @@ def _check_cjk_shape_purity(self) -> None: "reported the part. Since #613 the part is read and bound " "before any join, so no chain reaches it: each 'DO' leans " "credential by its capitals and the run reads whole in " - "silence behind another credential (rules/C1). 1.4.0 " + "silence behind another credential (rules/C11). 1.4.0 " "read the same fields; 2.0.0 read title 'PhD', given 'DO " "DO', and 2.3.0 given 'PhD', family 'DO DO John Smith'", shape=3), @@ -1263,7 +1263,7 @@ def _check_cjk_shape_purity(self) -> None: "slot's given is there and the member behind it reads " "as the credential, as for 'SMITH, JOHN MA'. 1.4.0 " "reads the same"), - # #575: rules/C1's count before the comma counts a particle + # #575: rules/C3's count before the comma counts a particle # chain (P2) as ONE name word -- it builds one family name -- so a # particle surname reads as a one-word surname does ('Royce, Ed', # 'Berg, MA', 'Berg, PhD'). @@ -1289,7 +1289,7 @@ def _check_cjk_shape_purity(self) -> None: {"family": "van der Berg", "suffix": "MA"}, classification="fix(#575)", ambiguities=("suffix-or-name",), - notes="one name word before the comma, so C1 reads the case " + notes="one name word before the comma, so C4 reads the case " "and capitals in a mixed-case name make 'MA' the " "credential, as 'Berg, MA' reads. The count had split " "the chain: given 'van', family 'der Berg'. 2.3.0 read " @@ -1389,7 +1389,7 @@ def _check_cjk_shape_purity(self) -> None: "counts the title as a word, so the comma reads as a " "credential comma and the part reads as it does alone, " "P1's fork and all. Unchanged"), - # #575: C1's count before the comma (`_comma._whole_name`, which + # #575: C3's count before the comma (`_comma._whole_name`, which # decides the positional read after a comma followed by no name # word) applies the title-particle exclusion # by position too. These two pin that flag both ways -- a review @@ -1456,13 +1456,13 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "PhD Ma"}, classification="fix(#544)", ambiguities=("suffix-or-name",), - notes="the trailing title keeps C1's run test from flipping " + notes="the trailing title keeps C4's run test from flipping " "the comma, so `_comma.decide` reads the part wholly " "as credentials plus the title (H5) and makes it a " "suffix comma, 'John Smith' keeping its positional " "read, and the member the anchor decided reports " "there: one report for 'Ma', where the twin 'John Smith, PhD Ma' reports " - "C1's flip once over the whole part. 2.3.0 read title " + "C11's flip once over the whole part. 2.3.0 read title " "'Prof.', given 'PhD', middle 'Ma', family 'John " "Smith', and 2.2.0 middle 'Ma Prof.'; 1.4.0 through " "2.1.0 title 'PhD', first 'Ma', middle 'Prof.'"), @@ -1470,7 +1470,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, PhD Ma", {"given": "John", "family": "Smith", "suffix": "PhD Ma"}, ambiguities=("suffix-or-name",), - notes="C1's run rule, reporting once over the whole part; the " + notes="C4's run rule, reporting once over the whole part; the " "pair to the row above. 1.4.0 read the same; 2.2.0 " "and 2.3.0 read given 'PhD', middle 'Ma', family 'John " "Smith', and 2.0.0 and 2.1.0 title 'PhD', given 'Ma'", @@ -2607,7 +2607,7 @@ def _check_cjk_shape_purity(self) -> None: "decision rides with it -- C2's structural flag already " "reports on the comma path, but reports what the parse " "could not recognize, not a fork it called " - "(rules/C1's exception, scoped to this class; P6's " + "(rules/C11's exception, scoped to this class; P6's " "attachment fork has reported on a family-comma path " "since 2.3, e.g. 'Berg, Jan vd')", shape=2), @@ -2623,7 +2623,7 @@ def _check_cjk_shape_purity(self) -> None: {"family": "Smith", "suffix": "Jr., MA"}, classification="fix(#289)", ambiguities=("suffix-or-name",), - notes="rules/C1's count for this class is of NAME words, " + notes="rules/C4's count for this class is of NAME words, " "not of words: two tokens and one name here, so the " "structure does not flip and the family survives. " "Counting tokens read it as given 'Smith' with suffix " @@ -2922,7 +2922,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#516)", notes="two NAME words before the comma read the part after " "it as the credential run, for the by-shape half " - "exactly as for the listed one (rules/C1). Silent " + "exactly as for the listed one (rules/C4). Silent " "since #563: three letters run together have no name " "reading a reader would weigh", shape=3), @@ -2932,7 +2932,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="#563: two words before the comma may be ONE surname, " "and two dotted letters are how a person's own initials " - "are written, so the count gives way (rules/C1). " + "are written, so the count gives way (rules/C5). " "2.4's first reading was given 'García', family " "'Márquez', suffix 'G.J.'; 1.4.0 and 2.3.0 read it as " "here, and only the report is new", @@ -2961,7 +2961,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#563)", notes="the contrast to the row above: the degree in front " "makes the pair a credential, and a flip on a dotted " - "word admitted by shape is silent (rules/C1). 2.3.0 " + "word admitted by shape is silent (rules/C11). 2.3.0 " "read given 'PhD', middle 'X.Y.'", shape=3), Case("a_title_in_front_of_paired_initials_does_not_speak", @@ -2969,7 +2969,7 @@ def _check_cjk_shape_purity(self) -> None: {"title": "Ms", "given": "G.J.", "family": "García Márquez"}, notes="#563 review round: 'Ms' is both title and suffix " "vocabulary, and in front of paired initials it is the " - "title of the given part they open (rules/C1, as S7 " + "title of the given part they open (rules/C5, as S7 " "reads a dual in that part's title run). The first fix " "let it speak and split the surname in silence. Every " "release reads it this way", @@ -2989,7 +2989,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="nobody writes initials as two dotted groups, so a " "second by-shape word makes the part the credential " - "run (rules/C1) -- reported, since here the pairs " + "run (rules/C5) -- reported, since here the pairs " "speak only for each other and each is a word a reader " "takes for a name. 1.4.0 read given 'X.Y.', middle " "'P.Q.'", @@ -3022,7 +3022,7 @@ def _check_cjk_shape_purity(self) -> None: "García Márquez, G.J.R.", {"given": "García", "family": "Márquez", "suffix": "G.J.R."}, classification="fix(#516)", - notes="the ACCEPTED cost of #563's line (rules/C1): " + notes="the ACCEPTED cost of #563's line (rules/C5): " "initials are conventionally written apart ('J. R. " "R.'), so three letters run together read as a " "credential even where the name before the comma is " @@ -3213,13 +3213,13 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Smith", "suffix": "PhD Do Ma"}, ambiguities=("suffix-or-name",), notes="the row above behind a suffix comma: the part after it " - "is the credential run C1 flips to, and assign reads a " + "is the credential run C4 flips to, and assign reads a " "tail segment wholly as suffixes, so 'Ma' ends a suffix. " "The chain may not report taking it into the name, a " "reading the parse never makes (rules/C2), and since " "#613 it is not run in the part at all, decide binding the " "part before any join; the one report is " - "C1's flip over the whole part. Carried a second, " + "C11's flip over the whole part. Carried a second, " "contradicting suffix-or-name on 'Ma' until 2026-09-28. " "1.4.0 read the same fields; 2.3.0 read given 'PhD', " "middle 'Do Ma', family 'John Smith'"), @@ -3439,7 +3439,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the all-caps SURNAME convention never writes the " "capitals after a comma behind a full name, so the " - "default reads the comma position (rules/C1, S9). " + "default reads the comma position (rules/C6, S9). " "2.3.0 read given 'XYZ', family 'John Smith'"), Case("the_comma_caps_reading_can_be_switched_off", "John Smith, XYZ", @@ -3458,7 +3458,7 @@ def _check_cjk_shape_purity(self) -> None: Case("an_all_caps_record_keeps_its_given_name_beside_a_mixed_credential", "LLOYD WEBBER, ANDREW PhD", {"given": "ANDREW", "family": "LLOYD WEBBER", "suffix": "PhD"}, - notes="#564 boundary: an all-caps word joins C1's run only where " + notes="#564 boundary: an all-caps word joins C4's run (C6) only where " "the NAME carries the contrast, a word of its own before " "the comma holding a capital and ending in lowercase " "-- here the only " @@ -3745,7 +3745,7 @@ def _check_cjk_shape_purity(self) -> None: "Title-case (leans NAME, #289) -- the caps run test " "must not admit a run the listed class already reads " "on its own, and it does not: what reads this part is " - "C1's listed run (#544), which counts the two name " + "C4's listed run (#544), which counts the two name " "words before the comma exactly as it does with the " "switch off, so the part is the credential run and " "the flip reports once. 1.4.0 read the same suffix; " @@ -3828,7 +3828,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Smith", "suffix": "LEED BA"}, classification="fix(#564)", ambiguities=("suffix-or-name",), - notes="#564: an unlisted all-caps word is a member of C1's run " + notes="#564: an unlisted all-caps word is a member of C4's run " "by shape, so beside a listed credential it reads as the " "run it is -- as 'John Smith, PhD XYZ' and 'John Smith, " "XYZ Jr.' do. Until #564 the listing form read given " @@ -3947,7 +3947,7 @@ def _check_cjk_shape_purity(self) -> None: notes="the SUFFIX arm of the same count, and the shape " "decisions/S6 names: 'Smith Jr., MA' is two tokens " "and one name. The Title-case spelling declines where " - "the all-caps one flips, which is the pair rules/C1 " + "the all-caps one flips, which is the pair rules/C4 " "states"), Case("strict_comma_reads_the_dotted_numeral_as_a_name_word", "Smith V., Ma", @@ -4015,7 +4015,7 @@ def _check_cjk_shape_purity(self) -> None: {"title": "MR", "given": "John", "family": "Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="`titles` end to end: the post-comma part holds no " - "name word, so C1's no-name-word clause keeps the " + "name word, so C8's no-name-word clause keeps the " "pre-comma positional read and 'MR' is the title it " "is at the default"), Case("a_period_final_delimited_clause_joins_the_shape_class", @@ -6525,7 +6525,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "Steven", "family": "Hardman", "suffix": "RN - CRNA"}, classification="fix(#603)", notes="a credential opening the part after the comma makes it " - "the postnominal part (rules/C1, #603), so the " + "the postnominal part (rules/C8, #603), so the " "pre-comma name keeps its positional read and the " "undeclared delimiter is one more word of the run. v1 and " "2.0 through 2.3 read v1's walk order here, the first " @@ -6537,7 +6537,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, Ma Jones", {"given": "Ma", "middle": "Jones", "family": "John Smith"}, ambiguities=("suffix-or-name",), - notes="the boundary of #603's opened part (rules/C1): only " + notes="the boundary of #603's opened part (rules/C8): only " "a word that starts S8's run opens it, and a member of " "the ambiguous credential class does not, so the part " "keeps the listing form's given name and the first " @@ -6545,7 +6545,7 @@ def _check_cjk_shape_purity(self) -> None: Case("a_dual_opens_no_part_behind_a_two_word_surname", "García Márquez, Ms Gabriela", {"title": "Ms", "given": "Gabriela", "family": "García Márquez"}, - notes="#603's boundary for a title/suffix dual (rules/C1): it " + notes="#603's boundary for a title/suffix dual (rules/C8): it " "opens nothing, being the listing form's title in front " "of a given name, and no count before the comma tells a " "two-word surname from a given name and a family name. A " @@ -6570,7 +6570,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "Ph. D. and Mary Jones"}, ambiguities=("suffix-or-name", "suffix-or-name", "suffix-or-name"), classification="fix(#603)", - notes="the two spellings agree (rules/C1): since #613 the " + notes="the two spellings agree (rules/C8): since #613 the " "part after the comma is read before group's connective " "join, so 'Ph. D.' opens it as 'PhD' does ('John Smith, " "PhD and Mary Jones' reads the same) and the words it takes " @@ -6583,7 +6583,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "PhD Jones"}, ambiguities=("suffix-or-name",), classification="fix(#603)", - notes="C1's one count before the comma, read off classify's " + notes="C3's one count before the comma, read off classify's " "tags (#613): a connective join is not one word there " "(#575, Derek on #613), so the part before the comma is a " "whole name and reads positionally, as 'Vega y Lopez, PhD' " @@ -6598,7 +6598,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=(), classification="fix(#613)", notes="decision (1) of #613, whole: the connective surname is " - "three words to C1's count (#575), so a comma followed by " + "three words to C3's count (#575), so a comma followed by " "a title alone is the suffix comma and the part before it " "reads as the comma-less 'Ortega y Gasset' does. 1.4.0 " "read first 'Ortega', middle 'y', last 'Gasset' too; " @@ -6620,7 +6620,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=(), classification="fix(#613)", notes="the one join made inside a part read as postnominal " - "(rules/C1, P3): a connective between titles binds " + "(rules/C7, P3): a connective between titles binds " "them into one title. #613's first draft bound each word " "on its own and lost it (`_comma._title_chains` then, " "group's `_pieces.join_connectives` since #617)"), @@ -6751,7 +6751,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, Ma I", {"given": "Ma", "family": "John Smith", "suffix": "I"}, ambiguities=("suffix-or-name",), - notes="contrast for the row below: C1's run test admits no " + notes="contrast for the row below: C4's run test admits no " "single-letter numeral, which could be an initial, so " "'Ma' is the given name, as b547c247 read it"), Case("a_generation_behind_a_class_member_makes_the_run", @@ -6933,7 +6933,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "MA", "family": "Smith", "suffix": "PhD Jones"}, ambiguities=("suffix-or-name", "suffix-or-name"), notes="#603's opener is the part's FIRST suffix word with only " - "titles in front of it (rules/C1): 'MA', a member of " + "titles in front of it (rules/C8): 'MA', a member of " "the ambiguous class, opens nothing and ends the leading " "run, so the 'PhD' behind it opens nothing either and the " "part is the walk's. Letting a later credential open it " @@ -6942,7 +6942,7 @@ def _check_cjk_shape_purity(self) -> None: {"family": "Smith", "suffix": "Jr. John"}, ambiguities=("suffix-or-name",), classification="fix(#603)", - notes="decisions/C1's accepted cost: a generational word " + notes="decisions/C8's accepted cost: a generational word " "starts S8's run, so it opens the part and 'John' is " "absorbed, one word before the comma reading the whole " "part as suffixes (Derek). 2.3.0 read title 'Jr.', given " @@ -7296,7 +7296,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("comma-structure",), notes="a declared core parts a tail as a comma would, so a " "connective beside it joins nothing across it and the " - "core is dropped (rules/C1, #549) -- 1.4.0's " + "core is dropped (rules/C10, #549) -- 1.4.0's " "expand_suffix_delimiter reading, where 2.0.0 through " "2.3.0 merged the core into the joined piece and " "rendered 'PhD - and MD'"), @@ -7320,7 +7320,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("comma-structure",), notes="the marker ends its part with nothing behind it, as it " "does before a typed comma, so it takes no clause " - "(rules/C1's Accepted entry, #549). 2.0.0 through " + "(rules/C10's Accepted entry, #549). 2.0.0 through " "2.3.0 read maiden 'Jones', stepping past the core"), Case("suffix_delimiter_core_that_survives_is_a_boundary_too", "Smith, MD - PhD - FACS", @@ -9419,7 +9419,7 @@ def _check_cjk_shape_purity(self) -> None: Case("bare_ambiguous_acronym", "John Ed", {"given": "John", "family": "Ed"}, ambiguities=("suffix-or-name",), - notes="'ed' is an ambiguous acronym; bare form is a name (C1), " + notes="'ed' is an ambiguous acronym; bare form is a name (S6), " "and the parse reports which reading it took"), # MOVED by #289, not deleted (roles unchanged, a report gained): # 'Ed' is Title-case in a mixed-case name, so it leans SURNAME and @@ -9803,7 +9803,7 @@ def _check_cjk_shape_purity(self) -> None: "apply and the merged credential fell through to the " "given name (a 1.4.0 regression -- v1 read suffix 'Ph. " "D.', title 'Jr.'). A run that is nothing but suffix " - "pieces is the credential run C1 describes, whole -- " + "pieces is the credential run C8 describes, whole -- " "and #429 made it render whole too, where #325 shipped " "it as 'Ph. D., Jr.' with a comma the writer never " "typed. The full-name 'John Smith, Ph. D. Jr.' rendered " @@ -9964,7 +9964,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix 'MD I'. Spelled with the dual 'MD' since #603: " "'Smith, PSM Dr. I', this row until then, is a part the " "credential 'PSM' OPENS, which reads as #602's run reads " - "whatever stands behind it (rules/C1) -- title 'Dr.', " + "whatever stands behind it (rules/C8) -- title 'Dr.', " "suffix 'PSM I' -- so the reset no longer decides it. A " "dual opens nothing, and the reset is what is left " "deciding this spelling. 1.4.0 read the " diff --git a/tests/v2/pipeline/test_classify.py b/tests/v2/pipeline/test_classify.py index 38ee33fc..cb86e1d8 100644 --- a/tests/v2/pipeline/test_classify.py +++ b/tests/v2/pipeline/test_classify.py @@ -805,7 +805,7 @@ def _surname_unit_disagreements() -> list[str]: def test_surname_unit_tags_agree_with_classify() -> None: - # #575: rules/C1's count before the comma reads two facts per + # #575: rules/C3's count before the comma reads two facts per # token -- particle, suffix -- and `segment` must build them from # the vocabulary (`_vocab.surname_unit_tags`), since it runs before # classify has tagged anything, while assign derives them from diff --git a/tests/v2/pipeline/test_comma.py b/tests/v2/pipeline/test_comma.py index baf39800..48a316f8 100644 --- a/tests/v2/pipeline/test_comma.py +++ b/tests/v2/pipeline/test_comma.py @@ -79,7 +79,7 @@ def test_the_credential_pair_merges_anywhere_in_the_run() -> None: def test_structure_flips_for_the_ambiguous_class_on_a_name_word_count() -> None: - # rules/C1 for the ambiguous class: two or more NAME words + # rules/C4 for the ambiguous class: two or more NAME words # before the comma read the part after it as the credential run. # 'John Smith, MA' flips; 'Smith Jr., MA' does not, having two # TOKENS and one name word -- which is what keeps Smith in the @@ -114,7 +114,7 @@ def test_structure_flips_for_a_by_shape_member_too() -> None: def test_paired_initials_need_a_word_to_speak_for_them() -> None: - # rules/C1 (#563): two dotted single letters are how a person's + # rules/C5 (#563): two dotted single letters are how a person's # own initials are written, and two words before the comma may be # one surname, so alone they keep the family comma. Each contrast # pair differs in the one thing that speaks: a third letter, a @@ -135,7 +135,7 @@ def test_paired_initials_need_a_word_to_speak_for_them() -> None: def test_a_flip_no_listed_member_takes_part_in_is_silent() -> None: - # rules/C1 (#563): a flip in which no listed member takes part + # rules/C11 (#563): a flip in which no listed member takes part # is silent, while a LISTED member in the same position still # reports the flip -- the control that keeps the silence from # being a lost emitter -- and so do paired initials spoken for @@ -184,7 +184,7 @@ def test_the_structure_flip_report_counts_the_real_pre_comma_words() -> None: def test_the_name_word_count_reads_a_run_as_it_reads_one_word() -> None: - # rules/C1 (#544): a part of two or more words, every one of + # rules/C4 (#544): a part of two or more words, every one of # them suffix vocabulary or a class member and at least one a # member, reads by the same NAME-word count as the single token, # and the flip reports once, over the whole part. @@ -237,7 +237,7 @@ def test_the_run_test_reads_a_delimiter_core_transparently() -> None: def test_the_run_test_reads_lenient_comma_suffixes() -> None: - # rules/C1's leftovers ask `is_wholly_suffix`'s own POLICY- + # rules/C4's leftovers ask `is_wholly_suffix`'s own POLICY- # selected predicate (#544): an # initial-shaped suffix WORD ('B.') that is not a single-letter # roman numeral is a member of neither the ambiguous class nor diff --git a/tests/v2/pipeline/test_script_segment.py b/tests/v2/pipeline/test_script_segment.py index b8bea10e..a20319dd 100644 --- a/tests/v2/pipeline/test_script_segment.py +++ b/tests/v2/pipeline/test_script_segment.py @@ -187,7 +187,7 @@ def test_one_word_before_the_comma_is_never_suffix_comma() -> None: def test_a_listed_ambiguous_credential_behind_two_name_words_divides() -> None: # #613 review: `_postnominal_behind_a_whole_name` asks the comma's # decision, which licenses a listed member of the ambiguous class - # by C1's name-word count, so the name before the comma divides as + # by C4's name-word count, so the name before the comma divides as # it does before 'PhD'. A name word after the comma and one word # before it are the contrasts: neither divides. divided = {"family": "김", "given": "민준", "middle": "박"} diff --git a/tests/v2/pipeline/test_vocab.py b/tests/v2/pipeline/test_vocab.py index 6dc200be..d6636985 100644 --- a/tests/v2/pipeline/test_vocab.py +++ b/tests/v2/pipeline/test_vocab.py @@ -463,7 +463,7 @@ def test_is_title_shaped_is_h2_s_shape_alone() -> None: def test_name_word_count_counts_names_not_tokens() -> None: - # rules/C1's count for the ambiguous class: 'Smith Jr.' is two + # rules/C4's count for the ambiguous class: 'Smith Jr.' is two # tokens and ONE name word, which is what keeps its family where a # token count would hand it to `given`. lex, pol = Lexicon.default(), Policy() @@ -840,7 +840,7 @@ def test_a_listed_member_written_in_period_closed_chunks_is_dotted( def test_is_single_letter_numeral() -> None: - """#544: the one-letter shape C1's run and the anchor both leave + """#544: the one-letter shape C4's run and the anchor both leave out -- one letter, periods allowed, that is a roman numeral.""" for text in ("V", "v", "I", "I.", "x", "X."): assert is_single_letter_numeral(text), text diff --git a/tests/v2/test_benchmark.py b/tests/v2/test_benchmark.py index f48222c4..8eacebe5 100644 --- a/tests/v2/test_benchmark.py +++ b/tests/v2/test_benchmark.py @@ -813,7 +813,7 @@ def test_the_paired_initials_title_scan_does_not_cost_quadratically() -> None: large_text = "John Smith, " + "MD " * _PAIR_SCAN_LARGE + "G.J. " * _PAIR_SCAN_LARGE # REACHABILITY: the flip REPORTS only where the scan ran and found # nothing but titles in front of the first pair, every shape word - # being a pair (rules/C1's "nothing but other paired initials"). + # being a pair (rules/C11's "nothing but other paired initials"). # A change that stopped the scan being asked -- 'MD' leaving the # titles, the pair test moving -- loses the report and fails here # rather than leaving this guard measuring nothing. diff --git a/tests/v2/test_doc_citations.py b/tests/v2/test_doc_citations.py index 27941d26..ea066041 100644 --- a/tests/v2/test_doc_citations.py +++ b/tests/v2/test_doc_citations.py @@ -318,7 +318,7 @@ def test_the_quotable_text_is_the_prose_and_only_the_prose() -> None: "all the same"), ("S2", "A suffix never BEGINS a name: position outranks " "the vocabulary match"), - ("C1", "The part is read as its words stand, before any " + ("C7", "The part is read as its words stand, before any " "join"), ("A1", "a kind is worth adding only if a reader would " "hesitate too"), diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index e478c07d..41006e10 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -1318,7 +1318,7 @@ def test_a_rendered_suffix_revises_back_to_itself(name: str) -> None: def test_revise_keeps_the_sub_parses_within_piece_mark() -> None: # The forcing keeps the sub-parse's tags: a "joined" made WITHIN a # piece (the Ph. D. merge, mid-value where the head rule lets it - # fire) is role-blind and right for every role (decisions/C1's + # fire) is role-blind and right for every role (decisions/R1's # #436 DECLINED list), and a draft that cleared it with the role # lost it on every non-suffix value. For a suffix value the pass # writes the marks itself ('Ph. D. MD' sub-parses with none), so @@ -1945,7 +1945,7 @@ def test_the_catalan_recipe_unmarks_the_link_for_one_case_names() -> None: # the comma shapes AGENTS.md asks of every comma grid ", inž.", "(Bob), inž.", - # paired initials (rules/C1) + # paired initials (rules/C5) "Janez Novak, Ž.Š.", "Janez Novak, Ž.Š., Jr.", # the all-caps credential shape: default policy reads it in the diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index 07a77f53..4535e478 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -3202,7 +3202,7 @@ def _comma_twin_findings(parser: Parser) -> tuple[list[str], int]: def test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin() -> None: - """rules/C1's statement: "a delimiter the policy declares parts a trailing + """rules/C10's statement: "a delimiter the policy declares parts a trailing suffix part as a comma would" (#549). Every role field of a name with a declared core standing in a part after the suffix comma, and every kind it reports but `comma-structure` (`_twin_record`), @@ -3210,7 +3210,7 @@ def test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin() -> None: place -- the maiden clause, the connective join and the entry boundary alike. The heads put the core in a TRAILING part only: before the first comma, or in the given part of the listing form, the core is - a word (C1's Accepted entry), and its comma twin a different + a word (C10's Accepted entry), and its comma twin a different structure. RECORDED NEGATIVE CONTROL: at 061f02da, where the join and the diff --git a/tests/v2/test_regex_sync.py b/tests/v2/test_regex_sync.py index a871f168..82072233 100644 --- a/tests/v2/test_regex_sync.py +++ b/tests/v2/test_regex_sync.py @@ -134,7 +134,7 @@ def test_dotted_initial_is_the_period_alternative_of_initial() -> None: # #544: the chunked sibling of _DOTTED ('M.Eng.'), S2's period gate # for a member with a lower-case tail; no config key to mirror ("_vocab", "_CHUNKED"): None, - # #563: paired initials ('M.J.'), C1's exception to the name-word + # #563: paired initials ('M.J.'), C5's exception to C4's name-word # count after a comma; no config key to mirror ("_vocab", "_PAIRED_INITIALS"): None, ("_group", "_PH"): None, From e08951be733a752992e721ea6aaa05c1136c51d0 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 18 Oct 2026 20:51:47 -0700 Subject: [PATCH 2/4] Record the two checks an ID split's fresh prose needs (#728) A split note's "means the rule as it then stood" holds only for pointers to the whole rule, and a split's new Rationales and boundary marks are normative text no guard reads. Both were found by the design-docs review of the C1 split. Co-Authored-By: Claude Opus 5.5 --- docs/design/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/AGENTS.md b/docs/design/AGENTS.md index 1979ae59..f4723ed3 100644 --- a/docs/design/AGENTS.md +++ b/docs/design/AGENTS.md @@ -10,7 +10,7 @@ rules/ states intended parsing behavior and is NORMATIVE; decisions/ is the date **Landing a design.** The gitignored spec (docs/superpowers/specs/) is the working medium; it dies with the branch, and the docs are the record. Before a design PR merges, walk its spec (including amendments) and distill the durable residue: decisions made or reversed → decisions entries; proposals rejected with evidence → Declined:; vocabulary that must stay out → Excluded:; behavior the design settled → rules/ (with a deviates: marker if unshipped); reusable patterns → mechanisms.md; options weighed → a weighing entry. Then check the spec cites nothing the docs don't now carry — a spec section with no committed home when the PR merges is lost, not deferred (a 2026-08-16 sweep of eight weeks of specs recovered nine such items). The same-PR amendment rule above covers code-driven changes; this covers the design-driven ones. -**Moving text between files breaks every positional reference in it, and in what pointed at it.** "The bullet above", "this file's Excluded block", "the W2 section" are true only where the text stood. #713 split decisions.md by heading and then moved the vocabulary-keyed blocks into decisions/vocabulary-exclusions, and its last review found thirteen such references left pointing at nothing: three inside the moved blocks, ten in entries elsewhere that located a block by position. The citation check cannot see them, because none is a citation. After a move, grep the moved text and the whole folder for `above`, `below`, `this file`, `this section` and the moved block's name, and turn each hit into a key-form pointer (`decisions/P3's bullet of that date`). Splitting an ID is the same move in the other direction (#682, S2 into S2 and S4–S9): every reference to the old ID needs a reader's choice of new one, and the references come in more spellings than the citation check reads — `rules/S2` and `decisions/S2`, a bare `S2` in rule prose, and the short forms older entries use, `#S2` and "the #544 entry under S2". #682's first pass retargeted the first three and its review found nine of the last kind still pointing at entries that had left the file; grep all five before calling the retarget done. +**Moving text between files breaks every positional reference in it, and in what pointed at it.** "The bullet above", "this file's Excluded block", "the W2 section" are true only where the text stood. #713 split decisions.md by heading and then moved the vocabulary-keyed blocks into decisions/vocabulary-exclusions, and its last review found thirteen such references left pointing at nothing: three inside the moved blocks, ten in entries elsewhere that located a block by position. The citation check cannot see them, because none is a citation. After a move, grep the moved text and the whole folder for `above`, `below`, `this file`, `this section` and the moved block's name, and turn each hit into a key-form pointer (`decisions/P3's bullet of that date`). Splitting an ID is the same move in the other direction (#682, S2 into S2 and S4–S9): every reference to the old ID needs a reader's choice of new one, and the references come in more spellings than the citation check reads — `rules/S2` and `decisions/S2`, a bare `S2` in rule prose, and the short forms older entries use, `#S2` and "the #544 entry under S2". #682's first pass retargeted the first three and its review found nine of the last kind still pointing at entries that had left the file; grep all five before calling the retarget done. #728 (C1 into C1 and C3–C11) added two more. A blanket note that an old ID in a moved entry "means the rule as it then stood" is true only where the pointer meant the whole rule: "C1's two-dotted-groups sentence" means C5 now, so retarget each pointer that names a piece. And the prose a split writes fresh is new normative text that no guard reads against its statement. That covers each piece's Rationale, and a `· boundary` added so a piece has one. #728's first C4 Rationale called two words before the comma "a whole name", which C5's exception denies. Its first C10 boundary carried no policy annotation, so the rule never ran on it. Read each new Rationale against its piece's exceptions, and run each new boundary under the annotation its rule needs. **A count in a dated entry is evidence, not a live fact.** decisions entries are snapshots by convention, so measurements belong in them — but a reader wanting TODAY's number must not have to trust the snapshot's date. Where an entry quotes something that drifts (vocabulary sizes, corpus counts, set compositions), give the one-liner that recomputes it, and phrase the argument so it survives the digits moving — "the two shares differ by orders of magnitude" outlives "58% vs 0.65%". A count that carries no argument is better deleted than dated: "over every name in the corpus, no prefilter" says what "over all 782 names" says, and cannot go stale. Do NOT reach for a test asserting the count — that is the constant-content pattern, and it fails on every legitimate vocabulary addition. #326 is the cautionary case: it quoted a vocabulary composition, carried a date, and was stale in five days. From e142e568e03bf4538894a264239193fcddfab39f Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 18 Oct 2026 20:51:47 -0700 Subject: [PATCH 3/4] Fix the design-docs review findings on the C1 split (#728) - C10's boundary is the Accepted clause's own input under the dash policy; the unannotated Steven Hardman line moves to C8, which it witnesses - C7's no-boundary reason names P5's example, the only one that parses a joined listing-form given part - C3's and C4's Rationales no longer overclaim (a connective surname counts its words; paired initials are the count's exception) - 'John Smith, PhD Jones' reporting moves from C8 to C11 - S9, S6 and C8's interacts: name the pieces they lean on - moved decisions pointers that meant a piece now elsewhere name it, as do five older pointers in S6, S4, P3, v1-xfail-triage and cases.py - the condensed #603 line keeps the one non-hand-read mover; the collapsed #575, #430 and #613 lines keep the clauses still in force - decisions/C4's entries are back in date order Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/C1-suffix-comma-decision.md | 2 +- .../decisions/C11-what-the-comma-decision-reports.md | 4 ++-- .../decisions/C3-counting-the-words-before-the-comma.md | 8 ++++---- .../C4-ambiguous-credential-class-at-the-comma.md | 2 +- .../decisions/C8-listing-forms-part-after-the-comma.md | 2 +- .../C9-one-character-suffix-word-inside-a-comma-part.md | 2 +- docs/design/decisions/P3-connectives.md | 2 +- .../S4-ambiguous-acronym-needs-words-to-spare.md | 2 +- .../design/decisions/S6-case-signal-at-the-suffix-slot.md | 2 +- docs/design/decisions/v1-xfail-triage.md | 2 +- ...C10-declared-delimiter-parts-a-trailing-suffix-part.md | 2 +- docs/design/rules/C11-what-the-comma-decision-reports.md | 1 + .../rules/C3-counting-the-words-before-the-comma.md | 2 +- .../rules/C4-ambiguous-credential-class-at-the-comma.md | 6 +++--- .../rules/C7-postnominal-part-is-read-before-any-join.md | 2 +- .../design/rules/C8-listing-forms-part-after-the-comma.md | 4 ++-- docs/design/rules/S6-case-signal-at-the-suffix-slot.md | 2 +- ...listed-words-that-join-the-ambiguous-class-by-shape.md | 2 +- tests/v2/cases.py | 2 +- 19 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/design/decisions/C1-suffix-comma-decision.md b/docs/design/decisions/C1-suffix-comma-decision.md index e9f0105a..c9739ec6 100644 --- a/docs/design/decisions/C1-suffix-comma-decision.md +++ b/docs/design/decisions/C1-suffix-comma-decision.md @@ -11,7 +11,7 @@ THE CJK RADAR TIER, settled in review and then by Derek. script_segment runs before group, so it cannot read decide's answer, and it needs one: whether the part after the comma is the postnominal part of a whole name decides whether the name before it divides and where a glued honorific peels from. The first draft asked a hand copy of C1 there, by the vocabulary alone, and the copy drifted from decide in four review rounds -- the ambiguous class's count (`김민준 박, MA` read as a credential run behind a two-word name while the name stayed whole), decide's numeral refusal and policy-selected suffix test (`김민준 박, MA V` divided behind a family comma, family '김 민준 박'), a suffix before the comma counted as a name word (`김민준 Jr., PhD` divided, the division then making decide agree), and the split `Ph. D.` (`김민준 Ph. D., PhD`) -- each drift dividing a name decide read as a family comma, or leaving whole one it read as a suffix comma. DECIDED (Derek, 2026-10-06, the /simplify pass): script_segment asks `_comma.decide` itself, on a classified copy of the name as written, and only where the answer can change something (the peel once its two candidate sites differ, the division once there is a token to divide), so no Latin or accented-Latin comma name pays for it. The division now follows decide in both directions (`test_the_division_never_outruns_the_comma_decision`: on 3320d700, the last tree with the copy, 0 of its 300 parses divided behind a family comma and 44 stayed whole behind a suffix comma; now 0 and 0). Against b547c247 that restores every reading the copy had moved (`Smith 김민준씨, XYZ`, `John 田中さん, XYZ`, `김민준 박, X.Y.Z.` divide and peel as master read them), and moves one class master read otherwise: a title and one CJK name word before a credential the capitals license, which decide reads as a suffix comma -- `Dr 김민준, MA` reads title 'Dr', given '민준', family '김', as `Dr 김민준` does (b547c247: family '김민준', the division declined and H1 making the one word the family), and the pre-existing tolerated row `Dr. 김민준씨, MA` moves with it; so does a glued honorific in a part decide reads as postnominal, `John Smith, Dr. J.씨` reading title 'Dr.', given 'John', family 'Smith', suffix 'J.씨' as `John Smith, J.씨` always did (b547c247 peeled the 씨 from the part and read given 'J.', family 'John Smith'). Measured on the /simplify pass's 78,282-parse fingerprint: 1,106 parses move, every one a CJK name before the comma or a `J.씨` after it. `마틴 킹, MA` reads as master did; its report counts three name words, decide reading the divided name. `_vocab.surname_unit_count`, which only the copy asked, is deleted. DELIMITER CORES are dropped only behind a whole name, which is rules/C10's Accepted v1 parity (`Smith, MD - PhD - FACS` keeps its cores): the first draft dropped them behind a one-word family too. WHAT MOVES, measured 2026-10-06 against b547c247 (PR #615's merge; this change is PR #616), over every corpus name and an 80,423-text comma grid under three policies: 3,631 texts move, 1,773 in a role, and every default-policy role mover falls in a class decided or accepted above (a connective before the comma 936, a suffix ending the part before it 456, a particle after it 250, a connective after it 65, the split `Ph.` … `D.` 31, and `Ph. D., Jr.`); the report-only movers are (4)'s `Freiherr von Berg` gains, (2)'s lost pair reports, and reports reordered or reworded. Four corpus names move, and the gate exits 0 at all five baselines. - COST, `sys.setprofile` call counts against b547c247 (PR #616): the reference parse and `John Smith` are unchanged and the comma shapes measured cost fewer frames or up to four more (`Smith, John` 179 → 160, `John Smith, PhD Jones` 361 → 262, `John Smith, MA` 249 → 253). The paired-initials speaker scan came back quadratic in the move while #563's guard, which counted `_normalize`, passed; the scan now finds the speaker once, and the guard counts what the scan calls. + COST, `sys.setprofile` call counts against b547c247 (PR #616): the reference parse and `John Smith` are unchanged (a no-comma name never enters `decide`, and decide returns before the counts where the part holds a name word and nothing of the ambiguous class, guarded by `test_a_listing_comma_with_nothing_to_decide_skips_the_counts`), and the comma shapes measured cost fewer frames or up to four more (`Smith, John` 179 → 160, `John Smith, PhD Jones` 361 → 262, `John Smith, MA` 249 → 253). The paired-initials speaker scan came back quadratic in the move while #563's guard, which counted `_normalize`, passed; the scan now finds the speaker once, and the guard counts what the scan calls. THE PR REVIEW, after the two rounds above. An EMPTY part before the comma had decide return early, leaving the part to the listing walk: `, PhD` read given 'PhD', `, Jr.` title 'Jr.', `, MD PhD` title 'MD', family 'PhD', where b547c247 and 2.3.0 read suffix -- decide now reads the part behind an empty head, which names no whole name, so the comma stays the family comma with the part bound (the heads of every grid above were non-empty, so none saw it). And three more costs the frame guards could not see, each now a clock row in `_PREFIXED_SHAPES` (base 1600, broken 9.9-11.9 against fixed 4.0-4.3): `_title_chains` rebuilding the growing title from a slice at each join, the paired-initials test copying the shape set per pair and scanning a list of the class members, and the core filter scanning a list of the cores. And one render view: decide cleared its caps-shape marks whenever C1's run test declined, so a part #603's opener bound anyway kept its all-caps word without them, and case repair wrote `John Smith, PhD Jones XYZ` as '... PhD Jones Xyz' (rules/R4); the marks now stay on every bound caps word that carries the name's contrast, roles and reports unmoved over the harness (`test_a_bound_caps_word_keeps_its_capitals_in_case_repair`). SUPERSEDES: decisions/C4's 2026-10-01 #562 bullet's mechanism and its accepted reports (its pair report and the settled-run test); decisions/C8's 2026-10-04 #603 bullet's "segment decides" wording; and decisions/C3's #575 bullet's account of WHY segment builds its unit facts from vocabulary -- `_comma._whole_name` counts over classify's tags, and `name_word_count`, which reads the vocabulary, is asked by decide and by script_segment, the agreement sweep `test_surname_unit_tags_agree_with_classify` keeping the vocabulary view equal to the tags. - 2026-10-18 #728 — SPLIT. rules/C1 held what are now C1 and C3–C11, and this file their history; each entry moved to the file of the rule it decided, and the #429, #436/#437 and #511 entries, which decide R1's entry pass, to decisions/R1. The entries kept here moved nothing but the pointers the split broke and the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR. The #611 entry stays here, an open question on #728. diff --git a/docs/design/decisions/C11-what-the-comma-decision-reports.md b/docs/design/decisions/C11-what-the-comma-decision-reports.md index 0d7f2054..fc5bd1a1 100644 --- a/docs/design/decisions/C11-what-the-comma-decision-reports.md +++ b/docs/design/decisions/C11-what-the-comma-decision-reports.md @@ -1,6 +1,6 @@ # C11 — what the comma decision reports - 2026-09-28 (Derek), #544 — A PART READ WHOLLY AS SUFFIXES REPORTS NO NAME READING OF ITS WORDS. group's particle chain runs over every comma segment, and its two emitters — `particle-or-given` when a particle behind a word of both the title and the particle vocabulary chains (since 2.0.0, de264af1) and `suffix-or-name` when the chain takes an ambiguous acronym into the name (#289/#516, 59d8f38a, in no release) — reported inside a TAIL segment, which assign reads wholly as suffixes. Each such report named a reading the parse never made, against rules/A1's "A report names the reading the parse took". A family comma's tail was already silent, since group hands the chain no report list anywhere after a family comma; the suffix comma's tails were not. Measured on the released wheels: `John Smith, Jr., Freiherr von Richthofen` reports `particle-or-given` on 'von', a token in the suffix role, at 2.0.0, 2.1.0, 2.2.0 and 2.3.0; `John Smith, Jr., PhD van Ma` and `John Smith, Jr., PhD Do Ma` report it on 'van' and 'Do' at 2.0.0 and 2.1.0 only. The `suffix-or-name` half reached `John Smith, Jr., PhD Do Ma`, `John Smith, MA, PhD Do Ma` and `John Smith, Jr., PhD van Ma` on master (e10e83b4), and the run rule of decisions/C4's 2026-09-27 #544 entry made it reachable behind ONE comma: `John Smith, PhD Do Ma` carried C1's flip and a second report on 'Ma'. FIXED by scope, not by a new test at the emitter: group passes the chain no report list in a tail segment either, so both emitters go quiet there together, and rules/C2 states the boundary for any part consumed wholly as suffixes. (Mechanism SUPERSEDED by #636, decisions/C7, 2026-10-11: group makes no chain in a tail segment at all, and the part after a suffix comma had reached none since #613, so the report list is withheld only after a family comma.) The maiden channel is a separate parameter and is untouched: a tail segment's reader is NONE, so the maiden walk reports nothing there to begin with. No mechanisms.md entry: this is AMBIGUITY-AT-THE-DECISION-SITE's own contract (a report fires only where the parse chose between live readings) applied to a stage whose reading a later stage overrides for the whole segment. MEASURED 2026-09-28, the tree against the same tree with `None if family_comma else ambiguities` restored in `group()` (the comparator), each parse recorded as its seven fields plus `(kind, [(token text, token role)])` per report, under all three name orders: 0 of the 1441 differential-corpus names move, so the gate has nothing to classify; over a comma grid — the prefixes `John Smith, `, `John Smith, Jr., ` and `Smith, John, ` times every run of one to three words drawn with repetition from {PhD, MA, Ma, Do, van, de, Jr, MEng, Ed, y, i}, each text as written, lowercased and uppercased, deduplicated to 11,049 texts — 360 parses (120 texts, every one of them mixed case) lose one `suffix-or-name` report apiece, every removed report on a token in the suffix role, 0 reports added, 0 field moves. 21 of the 120 texts carry one comma and are the run rule's reach; the other 99 carry two and moved the same way (297 parses) when the same one-line change was applied to master e10e83b4; the `Smith, John, ` prefix moves nothing, being a family comma. The grid holds no word of both the title and the particle vocabulary, so the `particle-or-given` half is witnessed by the case row alone. Pinned by the case rows `a_credential_run_after_the_comma_reports_no_chain_fork` and `a_part_past_the_second_reports_no_particle_fork`; the rows the chain still reports on outside a tail are `the_chain_reports_the_acronym_it_takes` and `titled_particle_chain_survives_a_title_that_is_also_a_particle`. -- 2026-10-01 (Derek) — A CAPITALS-SETTLED RUN OPENED BY A WORD OF THE CLASS REPORTS, AND RULES.MD#C1 NOW SAYS SO. C1 said a run whose every class word is written in capitals "reads whole in silence". That held only behind another credential: a class word OPENING the part is the first word after the comma, whose decision C1 reports either way, so `Doe, MA PhD`, `John Smith, MA MA` and `Smith, MA PhD` read wholly as suffixes and report `suffix-or-name` once, on that word, while `John Smith, PhD MA` and `Smith, PhD MA` are silent. The behavior predates #562 and is kept: nobody repeats `MA` at the end of their name on purpose, so the report is the right signal. Statement corrected, no parse moved. MEASURED 2026-10-01 on master b39c370c, the prefixes `John Smith, ` and `Smith, ` times every run of two or three words drawn with repetition from {MA, BA, ED, DO, JD, PhD, MD, Jr, Esq.} holding at least one class word: all 900 runs opening with a class word report, 895 of them read whole (14 of those hold a #562 particle pair, decisions/C4, whose count flip reports over the whole part rather than on the first word) and the other five being `Smith, MA DO DO` and its like, the one-name-word chain #562 left open; of the 560 opening with another credential, 554 read whole in silence, and the other six hold a #562 particle pair — four reading whole and reporting (`John Smith, PhD DO DO`), two being `Smith, PhD DO DO` and `Smith, Jr DO DO`. Recompute: parse that grid and bucket by whether the first word is a class word, whether `ambiguities` is empty, and whether the suffix is the whole part. The same day (Derek) rules/S2's first-slot precedence sentence gained the pointer to that exception, so the next reader does not rediscover it: S2 says the count decides the first slot after a family comma before case, true for one word (`John Smith, MA` flips by the count) and false for a capitals-settled run, where C1's shortcut reads the capitals first and the comma keeps its family reading. And S2's list of reporting slots lost "the segments beyond it": no `suffix-or-name` report lands past the second comma (`Smith, John, MA` and `John Smith, Jr., MA` are silent). It once did: at #530's merge cc78c960 `John Smith, Jr., PhD Do Ma` reported `suffix-or-name` on 'Ma' beside its `comma-structure` flag, from group's chain emitter, which the 2026-09-28 #544 bullet (decisions/C11) silenced in a tail. S2 now points at C2 for what such a part does report, which is not only `comma-structure` — an unclosed delimiter there adds `unbalanced-delimiter` (`John Smith, Jr., (Bob`), and a maiden clause standing in it is read as one (`Smith, John, Jr nee Jones MA`, maiden 'Jones MA'). Derek, 2026-10-01: the silence is right. -- 2026-10-11 (Derek), #641 — C2'S NO-NAME-READING CLAUSE MEANS A WORD READ AS A NAME, AND C1'S PER-WORD REPORT IS THE PART'S OWN. rules/C2 said a part the parse consumes wholly as suffixes "raises no report about reading a word of it as a name", whether it is the part after a suffix comma or a part beyond the second; but the part after a suffix comma reports each word its opening credential takes (`John Smith, PhD van der Berg` → suffix-or-name on 'van', 'der' and 'Berg', "follows a credential, so it reads as part of the suffix run; it may be a name word"), the report rules/S2 states ("A name word the run absorbs is reported `suffix-or-name`"), which C1's credential-opening branch reaches by reading the words after it as that run reads them, and which C1's example pins (`John Smith, PhD Jones` → `suffix-or-name`). The rule text and the code had disagreed since #603 added the report (in `assign`; #613 later moved it into `_comma.decide`), and no test held either side's wording. DECIDED (Derek): the report stays -- a word behind a credential in a part the comma made postnominal is the fork it reports in `John Smith PhD Jones` -- and C2's sentence is narrowed to what it meant: no report that a word of such a part was READ AS a name (a particle chained onto a name word, an acronym taken into the name). The part's own reports are listed by their rules: C1's report of the decision at its comma (the flip, or the first word after the comma), S2's report on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and C2's flag. Measured by the review over about 1.2 million parses of suffix-comma and tail parts: those are the only reports such a part carries. A text change only: no reading or report moves (the suite and the five gates unchanged), and the one code comment quoting the old sentence quotes the new. +- 2026-10-01 (Derek) — A CAPITALS-SETTLED RUN OPENED BY A WORD OF THE CLASS REPORTS, AND RULES.MD#C1 NOW SAYS SO. C1 said a run whose every class word is written in capitals "reads whole in silence". That held only behind another credential: a class word OPENING the part is the first word after the comma, whose decision C1 reports either way, so `Doe, MA PhD`, `John Smith, MA MA` and `Smith, MA PhD` read wholly as suffixes and report `suffix-or-name` once, on that word, while `John Smith, PhD MA` and `Smith, PhD MA` are silent. The behavior predates #562 and is kept: nobody repeats `MA` at the end of their name on purpose, so the report is the right signal. Statement corrected, no parse moved. MEASURED 2026-10-01 on master b39c370c, the prefixes `John Smith, ` and `Smith, ` times every run of two or three words drawn with repetition from {MA, BA, ED, DO, JD, PhD, MD, Jr, Esq.} holding at least one class word: all 900 runs opening with a class word report, 895 of them read whole (14 of those hold a #562 particle pair, decisions/C4, whose count flip reports over the whole part rather than on the first word) and the other five being `Smith, MA DO DO` and its like, the one-name-word chain #562 left open; of the 560 opening with another credential, 554 read whole in silence, and the other six hold a #562 particle pair — four reading whole and reporting (`John Smith, PhD DO DO`), two being `Smith, PhD DO DO` and `Smith, Jr DO DO`. Recompute: parse that grid and bucket by whether the first word is a class word, whether `ambiguities` is empty, and whether the suffix is the whole part. The same day (Derek) rules/S2's first-slot precedence sentence gained the pointer to that exception, so the next reader does not rediscover it: S2 says the count decides the first slot after a family comma before case, true for one word (`John Smith, MA` flips by the count) and false for a capitals-settled run, where C1's shortcut (rules/C4 since #728) reads the capitals first and the comma keeps its family reading. And S2's list of reporting slots lost "the segments beyond it": no `suffix-or-name` report lands past the second comma (`Smith, John, MA` and `John Smith, Jr., MA` are silent). It once did: at #530's merge cc78c960 `John Smith, Jr., PhD Do Ma` reported `suffix-or-name` on 'Ma' beside its `comma-structure` flag, from group's chain emitter, which the 2026-09-28 #544 bullet (decisions/C11) silenced in a tail. S2 now points at C2 for what such a part does report, which is not only `comma-structure` — an unclosed delimiter there adds `unbalanced-delimiter` (`John Smith, Jr., (Bob`), and a maiden clause standing in it is read as one (`Smith, John, Jr nee Jones MA`, maiden 'Jones MA'). Derek, 2026-10-01: the silence is right. +- 2026-10-11 (Derek), #641 — C2'S NO-NAME-READING CLAUSE MEANS A WORD READ AS A NAME, AND C1'S PER-WORD REPORT IS THE PART'S OWN. rules/C2 said a part the parse consumes wholly as suffixes "raises no report about reading a word of it as a name", whether it is the part after a suffix comma or a part beyond the second; but the part after a suffix comma reports each word its opening credential takes (`John Smith, PhD van der Berg` → suffix-or-name on 'van', 'der' and 'Berg', "follows a credential, so it reads as part of the suffix run; it may be a name word"), the report rules/S2 states ("A name word the run absorbs is reported `suffix-or-name`"), which C1's credential-opening branch (rules/C8 since #728) reaches by reading the words after it as that run reads them, and which C1's example pins (`John Smith, PhD Jones` → `suffix-or-name`). The rule text and the code had disagreed since #603 added the report (in `assign`; #613 later moved it into `_comma.decide`), and no test held either side's wording. DECIDED (Derek): the report stays -- a word behind a credential in a part the comma made postnominal is the fork it reports in `John Smith PhD Jones` -- and C2's sentence is narrowed to what it meant: no report that a word of such a part was READ AS a name (a particle chained onto a name word, an acronym taken into the name). The part's own reports are listed by their rules: C1's report of the decision at its comma (the flip, or the first word after the comma), S2's report on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and C2's flag. Measured by the review over about 1.2 million parses of suffix-comma and tail parts: those are the only reports such a part carries. A text change only: no reading or report moves (the suite and the five gates unchanged), and the one code comment quoting the old sentence quotes the new. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C3-counting-the-words-before-the-comma.md b/docs/design/decisions/C3-counting-the-words-before-the-comma.md index 50792ba8..ee2d3757 100644 --- a/docs/design/decisions/C3-counting-the-words-before-the-comma.md +++ b/docs/design/decisions/C3-counting-the-words-before-the-comma.md @@ -1,12 +1,12 @@ # C3 — counting the words before the comma - 2026-10-01 (Derek), #575 (where the count runs superseded by #613, decisions/C1) — THE COUNT BEFORE THE COMMA COUNTS A PARTICLE SURNAME ONCE. Every count rules/C1 makes of the part before the comma — v1's "more than one word" for an unambiguous credential, the ambiguous class's name-word count (#289, #544), and assign's two-name-word test for the positional read after a comma followed by no name word (#296/#325) — counted tokens or pieces, so `De La Cruz` was three words and `van der Berg` two (the ambiguous `van` stands as its own piece, P1's fork). This cycle's count had therefore read `De La Cruz, Ed` as a credential comma with no given name and split `van der Berg, MA` into given 'van', family 'der Berg' — an unreleased regression against 2.3.0, which read both as the listing form — and 1.4.0 through 2.3.0 all split `van der Berg, PhD`. DECIDED: a particle run and the one name word it attaches to are one word in all three counts, so a particle surname reads as a one-word surname does. - THE COMMA SETTLES P1's FORK, and that is the decision rather than a side effect. Standing alone, `Van Buren` is given 'Van', family 'Buren' with a `particle-or-given` report, so counting it as one surname makes the comma's reading disagree with the standalone one. Derek's call, after weighing the narrow alternative (count only a part P1 already reads as all surname, i.e. one led by a never-given particle like `de`): the listing form puts a surname before the comma, so the comma is evidence the standalone parse does not have, and the readings it gives are the ones a person takes — `Van Buren, Ed` given 'Ed', family 'Van Buren'; `van der Berg, MA` family 'van der Berg', suffix 'MA' (one name word, so C1 reads the case, and capitals in a mixed-case name make the credential). `Van Johnson, Dr.` moves with it, family 'Van Johnson', title 'Dr.', where 2.2.0 and 2.3.0 read given 'Van' and `tests/v2/pipeline/test_assign.py` had pinned the positional fork; that test now uses `Van Johnson Smith, Dr.`, which still has two name words. + THE COMMA SETTLES P1's FORK, and that is the decision rather than a side effect. Standing alone, `Van Buren` is given 'Van', family 'Buren' with a `particle-or-given` report, so counting it as one surname makes the comma's reading disagree with the standalone one. Derek's call, after weighing the narrow alternative (count only a part P1 already reads as all surname, i.e. one led by a never-given particle like `de`): the listing form puts a surname before the comma, so the comma is evidence the standalone parse does not have, and the readings it gives are the ones a person takes — `Van Buren, Ed` given 'Ed', family 'Van Buren'; `van der Berg, MA` family 'van der Berg', suffix 'MA' (one name word, so C1 reads the case (rules/C4 since #728), and capitals in a mixed-case name make the credential). `Van Johnson, Dr.` moves with it, family 'Van Johnson', title 'Dr.', where 2.2.0 and 2.3.0 read given 'Van' and `tests/v2/pipeline/test_assign.py` had pinned the positional fork; that test now uses `Van Johnson Smith, Dr.`, which still has two name words. THE PARTICLE REACHES ONE WORD, as P1's fold does, not to the end of the part as P2's chain does: `de Mesnil Jean, Dr.` under a family-first order is family 'de Mesnil', given 'Jean', two name words. The cost, and it is real for an ambiguous particle: a `van`-led part with a word after the surname counts two and keeps the positional read with P1's fork, so `van Buren John, Ed` reads given 'van', middle 'Buren', family 'John' and `van der Berg Smith, PhD` given 'van', family 'der Berg Smith', both unchanged from master. Under the default order a never-given particle loses nothing (P1's fold makes `de Mesnil Jean` all surname anyway). `_vocab.unit_ends` carries both reaches behind a `chain` flag, post_rules' fold reading the full chain as before. - ONLY PARTICLE CHAINS, not mechanisms.md#UNIT-PARTITION's full set — stated there as the Contract's one exception, and in P3's statement, whose "ONE name word wherever another rule counts them" now names C1's count as the exception. A bound given-name pair builds a GIVEN name, and P5 gives up a family word where the name has no other (`abdul Salam` alone is given 'abdul', family 'Salam'), so `abdul Salam, Ed` keeps the credential reading. Whether P3 joins a single-letter connective depends on the words of the whole name — `Carod i Rovira, Josep` joins as four words where `Ortega y Gasset` alone, three, does not — and the count is part of deciding what the whole name is, so segment could reach P3's answer only by copying P3; `Ortega y Gasset, Ed` keeps the credential reading. Approved as "particle chains and connective joins", narrowed to particles when P3's exception surfaced in implementation (the first draft joined `John e Smith, III` into one family name, caught by `tests/test_conjunctions.py`). + ONLY PARTICLE CHAINS, not mechanisms.md#UNIT-PARTITION's full set — stated there as the Contract's one exception, and in P3's statement, whose "ONE name word wherever another rule counts them" now names C1's count (rules/C3 since #728) as the exception. A bound given-name pair builds a GIVEN name, and P5 gives up a family word where the name has no other (`abdul Salam` alone is given 'abdul', family 'Salam'), so `abdul Salam, Ed` keeps the credential reading. Whether P3 joins a single-letter connective depends on the words of the whole name — `Carod i Rovira, Josep` joins as four words where `Ortega y Gasset` alone, three, does not — and the count is part of deciding what the whole name is, so segment could reach P3's answer only by copying P3; `Ortega y Gasset, Ed` keeps the credential reading. Approved as "particle chains and connective joins", narrowed to particles when P3's exception surfaced in implementation (the first draft joined `John e Smith, III` into one family name, caught by `tests/test_conjunctions.py`). A WORD THAT IS ALSO A TITLE IS NO PARTICLE WHERE IT OPENS THE PART. `TITLES ∩ particles` is `{freiherr, st}` and `bound_given_names ∩ particles` is `{abu, أبو, ابو}` (recompute: `L = Parser().lexicon; L.particles & L.titles`, `L.particles & L.bound_given_names`). The first draft counted every particle, and the docs review found it reading `Freiherr von Berg, PhD` as family 'Freiherr von Berg' and `St John, PhD` as family 'St John', where every release reads the title. The first fix then excluded both overlaps in EVERY position, and the review of that fix found two errors in it. Position: inside a surname a title-particle chains as P1 and P2 chain it, and the blanket exclusion brought #575's own defect back for `de St Pierre, Ed`, `De St. Croix, Ed` and `de Abu Bakar, Ed` (family only, no given name, where 2.3.0 read given 'Ed'). And direction: 2.0 through 2.3 read `Abu Bakar, Ed` as given 'Ed', family 'Abu Bakar', so excluding the bound-given particle gave released behavior up rather than keeping it — and Abu Bakar is a common Malay surname. DECIDED: a title-particle is no particle only where it LEADS the part, and a bound given-name particle is a particle. So `Freiherr von Berg, PhD` and `St John, PhD` keep the title, `de St Pierre, Ed` reads given 'Ed', and `Abu Bakar, Ed` reads given 'Ed', family 'Abu Bakar' while `Abu Bakar, PhD` reads family 'Abu Bakar', suffix 'PhD' where 1.4.0 through 2.3.0 read given 'Abu'. The agreement test compares each token in both positions; it cannot see which position a caller passes, so case rows pin each count's flag: `de St Pierre, Ed` segment's, and `St John, Dr.` and `von St Johann, PhD` assign's, one per direction (the review of this round found assign's flag fixed to either constant passing every test, and both mutants now fail). `Freiherr von Berg, Ed` reads family 'Freiherr von Berg', given 'Ed' — 2.0.0's through 2.3.0's reading too, so no change against a release; only this cycle's count had read title 'Freiherr', family 'von Berg', suffix 'Ed'. The surname counts once and the title is no name word, so the count reads the listing form, which keeps a leading title in the family as it already did for `Prof. Cruz, Ed` (superseded for listed titles by #H6, 2026-10-17: the count is unchanged, and the listed title then peels). For `Prof.` that is a cost (rules/C3's Accepted line); for a German rank it is the right reading (Derek, 2026-10-01): since 1919 a former noble title is part of the legal surname, written between the given name and the particle (Karl-Theodor Freiherr von und zu Guttenberg), so `Freiherr von Berg` is the family name. That is also why `freiherr` is in PARTICLES at all — added with the German prefixes in 3e14ea20 (#18) without a stated reason, and the reason is this one. `Freiherr von Berg, PhD` keeps title 'Freiherr', the unambiguous credential's count taking the title as a word; both are readings of one name, and neither is changed here. A title in front is otherwise outside the fix: v1's count for an unambiguous credential counts the title as a word, so `Dr. van der Berg, PhD` reads as `Dr. van der Berg` does alone, given 'van'. - `De La Cruz, M.J. K.L.` reads given 'M.J.', suffix 'K.L.', reporting twice, as `Cruz, M.J. K.L.` does (Derek, 2026-10-01): with one name word before the comma the paired-initials count no longer reaches it, and C1's two-dotted-groups sentence now says "behind two or more name words". #563 had read it as a flipped credential run with no given name. - SEGMENT RAN BEFORE CLASSIFY, so its count built its unit facts from the vocabulary while assign derived them from classify's tags. Superseded by #613 (decisions/C1): `_comma._whole_name` counts over classify's tags, and `test_classify.test_surname_unit_tags_agree_with_classify` keeps the vocabulary view equal to the tags (its negative control is `_SURNAME_UNIT_CONTROL`). + `De La Cruz, M.J. K.L.` reads given 'M.J.', suffix 'K.L.', reporting twice, as `Cruz, M.J. K.L.` does (Derek, 2026-10-01): with one name word before the comma the paired-initials count no longer reaches it, and C1's two-dotted-groups sentence (rules/C5 since #728) now says "behind two or more name words". #563 had read it as a flipped credential run with no given name. + SEGMENT RAN BEFORE CLASSIFY, so its count built its unit facts from the vocabulary while assign derived them from classify's tags over every token of the part, so a suffix word stops a particle in both (the first draft's assign count dropped suffix pieces first and read `van Jr. Berg, Mr.` as family 'van Berg', found by the code review). The mechanism is superseded by #613 (decisions/C1): `_comma._whole_name` counts over classify's tags, and `test_classify.test_surname_unit_tags_agree_with_classify` keeps the vocabulary view equal to the tags (its negative control is `_SURNAME_UNIT_CONTROL`). BLAST RADIUS, measured 2026-10-01 against 65afcdb3 (PR #576) over every corpus name under the three orders (fields, ambiguity kinds, `initials()`): the gate exits 0 at all five baselines and one of the 1459 names moves, `De La Cruz, M.J. K.L.` (decided above). Against 2.3.0 the move is a class rather than a list: a leading ambiguous particle and one word, before a comma followed by an unambiguous credential or a title alone, reads as one surname (`Abu Bakar, PhD`, `van Gogh, Jr.`, `Van Johnson, Dr.`). COST, `sys.setprofile` call counts against 65afcdb3 (PR #576): `John Smith, PhD` 209 → 217, the unambiguous credential path building its token list, and `Smith, John`, `John Smith, MA` and the benchmark reference unchanged. - 2026-10-06 (Derek), #613 — A CONNECTIVE SURNAME COUNTS ITS WORDS BEFORE THE COMMA, and a part before the comma that is one name word with a suffix in it is the listing form's family: decisions (1) and (4) of decisions/C1's #613 entry, recorded there with the decision they were taken with. diff --git a/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md b/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md index 601d663a..e75b0ebb 100644 --- a/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md +++ b/docs/design/decisions/C4-ambiguous-credential-class-at-the-comma.md @@ -2,8 +2,8 @@ - 2026-09-27 (Derek), #544 — THE NAME-WORD COUNT READS A RUN. The single-token rule rules/C1 then stated for the ambiguous class generalizes to a post-comma part of two or more words, every one suffix vocabulary or a class member (listed or by shape), at least one a member and none a single-letter roman numeral: behind two or more name words the part is the credential run, and the flip reports once over the whole part (`John Smith, Ed Ma`, `Jane Doe, MS LAc`). A title/suffix dual opening the part counts as suffix vocabulary there, and a run whose every member is listed and leans credential is left to the family-comma path, which already reads it whole. The forks and their measurements are decisions/S7's #544 entry. - 2026-10-01 (Derek), #562 — SUPERSEDED by #613 (decisions/C1, 2026-10-06), whose decision (2) binds the part before any chain runs and silences the pair report. Decided here then: a part holding two particles side by side, which group's chain had unsettled from the capitals' reading (`John Smith, PhD DO DO` read given 'PhD', family 'DO DO John Smith'), is read by the count, two name words before the comma flipping it to the credential run, reported; the reports that flip made on shapes the family-comma path had read whole were accepted (Derek: none of them is a name anyone would write on purpose). 1.4.0 read every one of these names as the fix did. Left open then, as #562 asked: `Smith, PhD DO DO`, one name word before the comma, and `John Smith, PhD van der`. -- 2026-10-06 (Derek), #613 — NOTHING AFTER THE DECISION UNSETTLES WHAT IT BOUND, so the #562 pair report above goes silent: decision (2) of decisions/C1's #613 entry. - 2026-10-01 (Derek), #554 — A WORD OF BOTH THE PARTICLE AND THE SUFFIX VOCABULARY COUNTS IN THE RUN, AND STILL SPEAKS FOR NOTHING. After #544 the run test (`_segment.py`) counts `vd` and `mc` as suffix words, while S2's company (`_pieces._anchors`) refuses to let them speak for the word behind them, an exclusion added within PR #552 after that PR's own anchor pass had brought back a split of `Smith vd Ma, John` into family 'Smith Ma', suffix 'vd'. That split is not only an intermediate state of #552: 2.2.0 and 2.3.0 shipped it (2.0.0 and 2.1.0 read family 'Smith vd Ma'), #530 removed it in this cycle, and #552 kept it removed. The issue framed that as two answers to one question and offered (b): end the run at such a particle, so that one "has a live name reading" predicate decides both. DECIDED (a), the shipped behavior, and no parse moves. They are two questions. Run membership asks what the word IS, and `vd` is unambiguous suffix vocabulary; company asks whether the word SPEAKS FOR the one behind it, and a particle cannot, P2 joining it forward to exactly that word. Both answers follow from the word's vocabulary, and they differ by design rather than by drift; S2's "end the run" is the company's run, not the part C1 counts. The clause reaches exactly `vd` and `mc`: `do`, also in both vocabularies, is in the ambiguous half and so is already a word of C1's class, reporting where these two do not (`John Smith, Jr do` against `John Smith, Jr vd`). Recompute the set: `L = Parser().lexicon; L.particles & (L.suffix_acronyms | L.suffix_words) - L.suffix_acronyms_ambiguous`, and drop the subtraction to see `do` with them. The evidence is the writing that carries no other signal. Neither reading of `vd Ma` after a comma is realistic, a particle in front of a Chinese family name being as rare as `vd` or `mc` as a credential, so the mixed-case writing is an arbitrary edge case and decides nothing. Written in one case, where S2 has no capitals to read and the vocabulary is all there is, the part reads as the credential run, and that is the reading a reader would take: measured 2026-10-01 on master (fc682e36), `JOHN SMITH, VD MA` and `john smith, vd ma` read given 'JOHN'/'john', family 'SMITH'/'smith', suffix 'VD MA'/'vd ma' and report `suffix-or-name`, as `John Smith, vd Ma` does, and `John Smith, mc Ma` reads the same in every casing. It is also 1.4.0's reading, verified against the released wheel the same day: 1.4.0 read suffix 'vd Ma' and 'VD MA', and 2.0.0 through 2.3.0 read the whole name as the family. (b) would add a particle exception to C1, and to #563's paired-initials test besides, in order to give `JOHN SMITH, VD MA` family 'JOHN SMITH VD MA' with no given name: the worse reading, on exactly the input where nothing else speaks. #563's site needs nothing of its own. `García Márquez, vd G.J.` reads as `García Márquez, PhD G.J.` does, in every casing: given 'García', family 'Márquez', suffix 'vd G.J.', and neither reports. If a surname split by a credential run in silence is a defect, it is the run's, not the particle's. Open: #573 — uniform-case `vd`/`mc` before a comma and after a family comma (decided 2026-10-02, decisions/P6's entry), and the silent mixed-case `Doe, Jane PhD vd Ma` (now #578). Recompute: `parse(s)` with `as_dict()` and `[a.kind.value for a in parse(s).ambiguities]` over each string above and its `.upper()` and `.lower()`. +- 2026-10-06 (Derek), #613 — NOTHING AFTER THE DECISION UNSETTLES WHAT IT BOUND, so the #562 pair report goes silent: decision (2) of decisions/C1's #613 entry. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C8-listing-forms-part-after-the-comma.md b/docs/design/decisions/C8-listing-forms-part-after-the-comma.md index 9f5f4f65..d736ed03 100644 --- a/docs/design/decisions/C8-listing-forms-part-after-the-comma.md +++ b/docs/design/decisions/C8-listing-forms-part-after-the-comma.md @@ -8,5 +8,5 @@ STRICT MODE: the veto refuses an initial-shaped word the suffix READING, not the run. `Smith, PSM I.` under `lenient_comma_suffixes=False` reads suffix 'PSM I.' (2.3: given 'PSM', suffix 'I.'), reporting the absorbed 'I.', which matches #602's no-comma run (`John Smith PhD V.` reports; its given-part run does not, a disagreement #602 left and this does not touch). THE SPELLING GAP THIS FOUND, FIXED HERE. `starts_a_credential_run` refused any piece longer than one token, so the split credential `Ph. D.` group merges started neither #602's run nor this one while `PhD` started both: `Smith, John Ph. D. Jones` read middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones'. The merged piece now starts the run, the "suffix" piece tag being the Ph./D. merge's alone; `trailing_candidates` had already admitted it, so its pre-check and the predicate now agree. The no-comma spelling `John Smith Ph. D. Jones` still reads family 'Jones': the trailing peel's walk never holds the merged piece, which is a separate path and is left (AGENTS.md's spelling sweep). C2, WIDENED (Derek): a word of the title vocabulary in a part past the second comma that is not also suffix vocabulary is a title -- the part stands behind a comma, the line H5 draws for reading a title from the end of a name -- and a piece holding a suffix word stays a suffix, a dual and a connective join that took one (`PhD - and MD`) alike. `Eric H. Holder, Jr., Attorney General` reads title 'Attorney General', and a part of title and suffix words is recognized rather than flagged. The flag's test is asked word by word against the title vocabulary, a period-joined word through `period_joined_vocab` as classify tags it (`John Smith, Jr., Lt.Gov.`, which the code review found still flagged), so `Secretary of State` reads its title but keeps `comma-structure`: knowing that group's title chain takes the connective would be `segment` modelling `group`, the sign #611 is about. Every release through 2.3.0 read these as suffixes, 1.4.0 included (`Eric H. Holder, Jr., Attorney General` suffix 'Jr., Attorney General', `Smith, John, Prof.` suffix 'Prof.', measured with the released wheels 2026-10-04); a first draft of this entry said 1.4.0 read them as titles, inferring it from the 1.4.0 run reporting no unexplained diff, when the reason was the broad `fix(comma-family)` rule there claiming them, which #603's two rules now precede by declaration. Accepted consequence: a noble rank there reads as a title, `John Smith, Jr., Freiherr von Richthofen` → title 'Freiherr', suffix 'Jr., von Richthofen'. - WHAT MOVES, measured 2026-10-04 against 97d36e0d (PR #615): four of the 1,515 corpus names, every one intended (`Steven Hardman, RN - CRNA` now reads given 'Steven', suffix 'RN - CRNA', and `John, Smith, Dr.`, `Andrew Perkins, Jr., Col. (Ret)` and `1 & 2, 3 4 5, Mr.` read their tail title); and 589 of 98,630 generated texts under three policies, 581 of them a part a credential that is not also a title opens or a title past the second comma, the other eight read by hand. Frames are unchanged on the reference band and plain names, and `John Smith, PhD Jones` pays 338 → 361. + WHAT MOVES, measured 2026-10-04 against 97d36e0d (PR #615): four of the 1,515 corpus names, every one intended (`Steven Hardman, RN - CRNA` now reads given 'Steven', suffix 'RN - CRNA', and `John, Smith, Dr.`, `Andrew Perkins, Jr., Col. (Ret)` and `1 & 2, 3 4 5, Mr.` read their tail title); and 589 of 98,630 generated texts under three policies, 581 of them a part a credential that is not also a title opens or a title past the second comma, one the two-name-piece count reading a connective-joined piece beside a suffix word as the family (`Jr. i Ó VI Attorney, Ph.`), and the other seven read by hand. Frames are unchanged on the reference band and plain names, and `John Smith, PhD Jones` pays 338 → 361. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md b/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md index 008ed7f6..44a9a701 100644 --- a/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md +++ b/docs/design/decisions/C9-one-character-suffix-word-inside-a-comma-part.md @@ -4,7 +4,7 @@ #430's cause was rules/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. 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, over the 1069 corpus names against 56fb505c (PR #443): seven carry a one-character numeral behind a word inside a comma segment, every one bare, and none moves; the only movers are the three names this change added as examples, so the case table is the whole coverage. + BLAST RADIUS, over the 1069 corpus names against 56fb505c (PR #443): seven carry a one-character numeral behind a word inside a comma segment, every one bare, and none moves; the only movers are the three names this change added as examples, so the case table is the whole coverage. `Berg, abdul V` cannot reach the walk at all, the bound-given join making `abdul V` one piece, which is why the LENIENT mirror #401/#421 declined does not collide here. 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/C8's #428 review entry 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. diff --git a/docs/design/decisions/P3-connectives.md b/docs/design/decisions/P3-connectives.md index e20d73e3..c7734f0d 100644 --- a/docs/design/decisions/P3-connectives.md +++ b/docs/design/decisions/P3-connectives.md @@ -58,7 +58,7 @@ - 2026-10-06 #617 — the comma decision joins title words with GROUP'S connective loop, not a copy of it. `_comma.decide` reads the part after the first comma before group joins anything (#613), and the one join the part can hold — a connective binding title words into one title, `John Smith, Mr. and Mrs.` — was made by `_comma._title_chains`, a second copy of the P3 loop in `_group_segment`. The two had already parted three ways: the copy merged no connective run before joining, skipped a single-letter connective unconditionally where group skips it only under the rootname count's carve-out, and derived the title kind alone. Both now call `_pieces.join_connectives` (mechanisms.md#ONE-PREDICATE-PER-QUESTION), which takes the carve-out's answer from its caller (`letter_stays`: group's `total < 4`, always true for the comma decision's own reading, and reaching a LONE single-letter connective only, one beside another connective merging into their run first), a `titles_only` mode for the comma part (a join is made only into a title and takes no prefix kind, any other connective left a word for the reading to place, which is what the copy did), and the `frozen` token set group already passed itself. The merge and the piece predicates it needs moved with it (`merge_pieces`, `joined_tags`, `is_conj_piece`, `is_prefix_piece`), and `_comma._reading_pieces` builds the reading's input, calling the shared loop only where the part holds a connective left free — nearly no part does, and the first cut, calling it always, cost every family-comma parse two frames per word of the part, one `is_conj_piece` call in each of the loop's two passes (`Smith, John` +2, `Smith, John` with sixteen more given words +34, measured inside decide in #617's review); `test_a_comma_part_with_no_free_connective_skips_the_join` holds the gate, with that control recorded. DECIDED (Derek, 2026-10-06): (1) A CONNECTIVE RUN merges in the comma part as it does in the name — P3's own "connective runs included" — so a title holding `of the` reads whole there too, and a single letter beside another connective joins as a member of their run. `John Smith, Minister of the Interior and Mrs.` reads given 'John', family 'Smith', title 'Minister of the Interior and Mrs.' (83f4e914: family 'John Smith', the copy having joined 'Minister of the' alone and left 'Interior' a name word); `Smith, Chancellor of the Exchequer MP` reads suffix 'MP' (83f4e914: given 'MP'); `John Smith, Mr. y and Mrs.` reads title 'Mr. y and Mrs.' behind given 'John' (83f4e914: family 'John Smith'). The garbage case the issue named, `Prof. Smith, and and Dr.`, reads title 'Prof. and and Dr.', family 'Smith' (83f4e914: title 'and and Dr.', family 'Prof. Smith'). Every one of these reads as 2.3.0 read it, checked against the released wheel, and so did b547c247: what moves on realistic input was #613's copy regressing names it never meant to touch, between releases, so no release note follows. A LONE single-letter connective is unchanged: the comma decision's reading joins no title across one, so a part of titles it separates is no titles-alone part and the listing form reads it, where P3's own count decides the join — `Juan Garcia Lopez, Sr. y Sra.` keeps given 'y' and title 'Sr. Sra.', while `Smith, Sr. y Sra. Juan Maria` joins title 'Sr. y Sra.' behind given 'Juan', both as 2.3.0 and 83f4e914 read them. A case row pins the first. No rules.md text states it, because it is no rule of the comma's: the first review round added a C1 clause saying a lone letter after a comma always stays a word, and the second struck it with the `Juan Maria` reading, P3 deciding in the listing part as everywhere else. (2) EVERY CONNECTIVE OF THE GENERATIONAL CLASS IS FROZEN in the part the comma decision reads, P3's "joins only where a name word stands on each side of it" applied to a part read for credentials, which holds no name word — group freezes the ones it finds without a name word beside them, the same rule. Frozen means no join or run of its own; a neighbour's join may still take one in, as in group (`Smith, Dr. and i` reads title 'Dr. and i'). The default class is `i`, which as one letter the copy never joined and which reaches the freeze only through a run: without it the shared run merge took `Rovira, Dr. i i` into title 'Dr. i i', which `test_a_link_that_joins_nothing_changes_no_field` caught. A generational word of more letters reaches it in a plain join too, which only caller vocabulary can spell, and there the copy DID join it: under `Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"})`, `Smith, Mr. og Mrs.` reads title 'Mr. Mrs.', suffix 'og' (83f4e914 and 2.3.0: title 'Mr. og Mrs.'; b547c247 as now), and as a plain connective `og` still joins. MEASURED 2026-10-06 against 83f4e914, py3.11, over that tree's population — each branch-added case row and the new rules.md example are fixes against it by construction and not counted here. Two session harnesses, recorded as recipes: each parses its names on both trees and compares the seven role fields, every ambiguity's kind and detail, and `initials()` (the fingerprint adds `capitalized()` and each ambiguity's token spans). The FINGERPRINT (78,282 parses): every corpus name and case row, plus 6,000 seeded draws (`random.seed(7)`) of a front from `'', Smith, John Smith, John Q. Smith, Dr. Smith, de la Vega, SMITH, LLOYD WEBBER, Smith Jr., Vega y Lopez, 김민준 박, 김민준, Dr 김민준` and one to four words behind its comma from the 40 words `Smith John Jane de van vd VD mc do DO MA Ma PhD Ph. D. MD Jr. Jr V V. VI III G.J. XY XYZ X.Y.Z. Dr. Mr. and Prof. Sir i Und RN/CRNA / Jones 김민준 박 J.씨 씨`, three in ten with a third part (each draw in that order: `choice(fronts)`, `randint(1, 4)`, `choice(words)` per word, `random() < .3`, `choice(words)`), plus every two-word pair from that list behind `John Smith, `, `, ` and `Doe, Jane `, under six policies (default, both family-first orders, strict comma suffixes, `/` as a suffix delimiter, caps suffixes off); 30 parses differ, every one a generated name with two connectives side by side after the comma, and no corpus name and no case row moves. The CONNECTIVE GRID (485,300 parses): every three-word string over the 30 words `and of the y e i Und & Mr. Mrs. Dr. Prof. Secretary State Sir PhD MA MD Jr. III V de van der la John Smith Jones J. Ma`, bare and behind `John Smith, `, plus 40,000 seeded draws (`random.seed(617)`) of a front from `'', Smith, John Smith, Dr. Smith, Prof. Smith, de la Vega, Vega y Lopez, SMITH` and one to five of those words, written both as `front, words` and `words front`, three in ten with a third part (drawn in the fingerprint's order, `randint(1, 5)`), under four policies (default, family-first, strict comma suffixes, `&` as a suffix delimiter), and 2,700 parses under the `og` lexicon, every pair of those words in the three templates `John Quincy Smith og {a} {b}`, `John Smith, og {a} {b}` and `Smith, {a} og {b}`; 5,911 differ, 5,590 with two connectives side by side after the comma counting `og` as one, the other 321 the `og` freeze; 4,131 move a role and 1,780 only reports, and in every one of those 1,780 each report the branch adds names a run of two or more connectives, reported once as one run (`John Smith, III and and PhD` reports `'and and' follows a credential` where it reported each `and`). The five differential gates exit 0 and no diff moves but the new example's; that one new rules.md example reads as every 2.x baseline reads it and moves three 1.4.0 rules' corpus reach by one each. Frame cost falls by 10 a parse on every interpreter's reference row, and by 39 more once the /simplify pass gated group's call as the comma's is gated (decisions/parse-cost, this date); per name on 3.11, warm, against 83f4e914, `Smith, John`, `John Smith, PhD` and `John Smith, PhD MD MBA RN` cost what they did (161, 206, 294), a long given part less (`Smith, John Quincy Adams Bob` 332 → 297), and a comma part holding a connective pays the shared loop (`John Smith, Mr. and Mrs.` 262 → 269). - AMENDS decisions/C1's 2026-10-06 #613 entry, whose decision (3) names `_comma._title_chains` as where the title join is made and describes the join as P3's connective binding title words: it is `_comma._reading_pieces` calling `_pieces.join_connectives` now, and the join merges a connective run first and freezes the generational class, so (3)'s account holds of the join's PRODUCT, one title, and no longer of its reach — rules/C1 is amended to match. The `_PREFIXED_SHAPES` `title_chain` row, recorded there as the clock guard on the copy's slice rebuild, guards the shared loop on the same input (`'Mr. and ' * n` behind `John Smith, `), its control re-taken on the moved code (tests/v2/test_benchmark.py records it); the `_SHAPES` `conjunctions` row guards group's calls as before. + AMENDS decisions/C1's 2026-10-06 #613 entry, whose decision (3) names `_comma._title_chains` as where the title join is made and describes the join as P3's connective binding title words: it is `_comma._reading_pieces` calling `_pieces.join_connectives` now, and the join merges a connective run first and freezes the generational class, so (3)'s account holds of the join's PRODUCT, one title, and no longer of its reach — rules/C1 (rules/C7 since #728) is amended to match. The `_PREFIXED_SHAPES` `title_chain` row, recorded there as the clock guard on the copy's slice rebuild, guards the shared loop on the same input (`'Mr. and ' * n` behind `John Smith, `), its control re-taken on the moved code (tests/v2/test_benchmark.py records it); the `_SHAPES` `conjunctions` row guards group's calls as before. - 2026-10-07 #614 — CROSS-REFERENCE: P3's joins are made before S2's trailing run is read, so the run counts a joined part as one name word, as this rule says every count does (`Juan y Garcia X.Y.Z.` keeps family 'X.Y.Z.'), and a salutation keeps its connective (`Mr. and Mrs.` reads title 'Mr. and Mrs.'). #614's first design read before these joins too and broke both; #614's review reversed it (decisions/S2, this date). - Provenance: the single-letter-connective guard is v1's fix for Google Code issue 11 ("john e smith", 2013, commit 33676c9) — the "#11" citations that circulated pointed at a GitHub accident, not the real source. Recorded so the archaeology stays done. diff --git a/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md b/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md index faec3522..8d514f5c 100644 --- a/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md +++ b/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md @@ -2,7 +2,7 @@ - 2026-09-15 (spec review) — THE COUNT REACHES THE LISTED SET TOO — one uniform rule for the whole ambiguous class rather than two. `John Smith, Ed` → given John, family Smith, suffix Ed; `john smith, ma` and `JOHN SMITH, MA` move the same way, each being a one-case name that takes the positional rule, which now reaches the comma structure. All three are 1.4.0 RESTORATIONS — measured on the wheel, v1 reads all three as suffixes — so today's tree is what deviates and this restores parity rather than opening distance from it. Corpus population: ZERO (verified by grepping `tools/differential/corpus*.jsonl` for all three; `Royce, Ed` is the corpus's only member of the sibling one-word shape), so no corpus mover count moves and `Davis Royce, Ed` is the case row that carries the decision. - 2026-09-17, corrected — THE COMMA-PATH REPORT TRACKS THE FORK CONSULTED, NOT THE LEAN. A first pass gated the family-comma report on whether `ambiguous_lean` fired, which wrongly silenced `Smith, ma` (all-lower, no lean) and `毛泽东, MA` (caseless, no lean) — the TRAILING slot has never gated its own report on the lean either (a bare ambiguous acronym reported before #289 too, pick declined or not), because the report is about a fork the parse CONSULTED, and the fork is consulted whenever a class member sits in the slot, lean or no lean. Fixed: the guard is class MEMBERSHIP on the first post-comma piece (`vocab:suffix-ambiguous` or `shape:acronym`, never the lean) — `Smith, ma` reports `"'ma' after the comma is also an ordinary name word; read as the given name"` (since #626, 2026-10-08: "read as a given name", the field worded from where the word lands), `毛泽东, MA` reports the same wording for `'MA'`, and `Smith, MA PhD` (a two-piece post-comma part) reports through its FIRST piece regardless of what follows it, exactly as a one-piece part would. The structure decision reports in `segment`, where that branch is taken, and the family-comma reading in `assign`, so one DECISION never reports twice — a second ambiguous token elsewhere is a second fork and reports on its own (`Smith MA, Ed` carries two `suffix-or-name` reports, one per fork, plus a `given-or-family` for the lone remaining word). -- 2026-09-14 (Derek), scope corrected 2026-09-18 — NO NEW AmbiguityKind. Every decision at these slots emits the existing `AmbiguityKind.SUFFIX_OR_NAME` — no new kind — including the comma path, which is the first report of the comma's OWN decision (listing or credential run) in the library (`COMMA_STRUCTURE` already reports on the comma path, but as a shape the parse could not recognize, not a fork it called); the comma-quiet policy in `_types.py` and rules/C1 gain an exception scoped to the ambiguous class. Switch B off emits nothing. This is narrower than an earlier wording of this entry claimed: an attachment decided AFTER a family comma is a separate fork, P6's, and it has reported on its own since 2.3 — measured on this tree and at master `1f78bef` (2.3.0), `parse("Berg, Jan vd")` gives given `'Jan'`, family `'vd Berg'`, `ambiguities=['suffix-or-name']`, emitted from `_pipeline/_post_rules.py`'s attachment fork, and `AmbiguityKind.SUFFIX_OR_NAME`'s own docstring in `_types.py` already lists that family-comma case. +- 2026-09-14 (Derek), scope corrected 2026-09-18 — NO NEW AmbiguityKind. Every decision at these slots emits the existing `AmbiguityKind.SUFFIX_OR_NAME` — no new kind — including the comma path, which is the first report of the comma's OWN decision (listing or credential run) in the library (`COMMA_STRUCTURE` already reports on the comma path, but as a shape the parse could not recognize, not a fork it called); the comma-quiet policy in `_types.py` and rules/C1 (rules/C11 since #728) gain an exception scoped to the ambiguous class. Switch B off emits nothing. This is narrower than an earlier wording of this entry claimed: an attachment decided AFTER a family comma is a separate fork, P6's, and it has reported on its own since 2.3 — measured on this tree and at master `1f78bef` (2.3.0), `parse("Berg, Jan vd")` gives given `'Jan'`, family `'vd Berg'`, `ambiguities=['suffix-or-name']`, emitted from `_pipeline/_post_rules.py`'s attachment fork, and `AmbiguityKind.SUFFIX_OR_NAME`'s own docstring in `_types.py` already lists that family-comma case. THREE READING SITES, NOT TWO. The trailing suffix slot (`_pieces.peel_trailing`/`listed_lean`) and the post-comma given slot (`_pieces.segment_suffix_reading`) are the two the decision names first; `_vocab.is_wholly_suffix` over a `groups[2:]` TAIL segment is the third, and part of the blast radius rests on it: `Steven Hardman, MD, DO, DDS` loses its `comma-structure` flag only because the lean makes segment 2 (`DO`) wholly suffix. That is the one place this arc QUIETS a report rather than adding one, and it has a case row of its own because a quieted flag is invisible to a reader sweeping for new ones. - 2026-09-18 — A KNOWN ROUGH EDGE UNDER `lenient_comma_suffixes=False`, FOR DEREK'S ATTENTION. Measured: `Smith V., Ma` gives given `Smith`, family `V.`, suffix `Ma`, and `Smith V, Ma` gives given `Smith`, suffix `V, Ma`. The cause is the strict knob rather than the credential class: under strict, the initial-shaped `V.` fails the suffix test, so `name_word_count` sees TWO name words before the comma, flips the structure, and `Smith V.` is then read positionally. The control that shows it is the knob's and not this arc's: `Smith V., PhD` reads given `Smith`, family `V.`, suffix `PhD` under the same knob on master. Consistent, then, and left as it is — recorded with case rows so a future change to either the knob or the count has to notice it. - 2026-09-19 (Derek), #533 — THE CLASS'S LAST SILENT TRAILING POSITION IS CLOSED, AND #531'S READING NOW LIVES IN ONE PLACE. The slot list in rules/S2 gains the trailing slot of a maiden marker's clause, and #S3's enumeration gains it with the by-shape spellings; the rule that governs what the clause does with those words is M2's, and decisions/M2 is where its reasoning lives. The gap decisions/S5's #531 bullet of 2026-09-18 left open as out of scope is the one this closes, from both sides: `Doe, Jane nee Smith MA` now gives maiden 'Smith' with suffix 'MA' and reports, and `John Smith nee Jones R.A.I.` gives suffix 'R.A.I.' again — a RESTORATION rather than a change, since 2.3.0 read it that way (measured on the wheel) and this unreleased cycle moved it into the maiden name when #516 took dotted tokens out of the certain-suffix class, where no corpus file held the name and no gate could see it. Two things belong here rather than under M2. FIRST, `credential_at_the_given_slot` in `_pipeline/_pieces.py` is now where #531's reading of a class member ending the given part is written, with two callers — assign's walk over that part, and the maiden walk's second check over the name a take would leave. Spelling it twice is precisely the "condition written to match it" mechanisms.md#ONE-PREDICATE-PER-QUESTION names, and the drift would have been silent, since each site's own tests would have gone on passing. It is a text-and-tags question, which is what puts it in `_pieces` rather than beside either caller — the destination follows the LAYER, not the topic. It costs one frame PER MEMBER asked at that slot: measured 2026-09-19 against 2f57ff21 per `Parser.parse`, `Doe, John MA` goes 310 → 311 and `Doe, John MA Ma MA`, which asks four times, 439 → 443, while a name with no member there never reaches it and pays nothing. The reference band does not move (412/449 on `uv run python tools/perf/call_count.py`), no test pins 310, and Derek took the trade rather than keep two conditions in step across two stages with no test that asks them both. The maiden path got one frame CHEAPER in the same pass, the numeral reading calling the peel pair it had wrapped rather than the wrapper: `Jane Doe nee Smith` 249 → 248. SECOND, THE ONE-CASE HEAD IS AN ACCEPTED EXCEPTION AND IT IS THE M2 INSTANCE OF #492'S DEFERRED QUESTION about whether a cased suffix token counts as case evidence. `DOE, JANE nee Smith Ma` reads suffix 'Ma' where the clause-less `DOE, JANE Ma` keeps middle 'Ma', because the own-words span stops at the marker — rules/P3 puts "a maiden marker's run and every word after it" outside the name's own words from the moment the marker is tagged — so the member's own Title-casing is not in the span `one_case` is computed over and the clause HIDES the contrast. That is THE PREDICATE KEEPS THE JUDGED TOKEN IN THE SPAN, decisions/S6's 2026-09-14 #289 + #516 entry, failing structurally rather than by oversight: at this slot the judged token is never in the span, and that entry's own answer — include it — cannot be had here. Widening the span for this one question would change `one_case` for the whole name, and three sites read it, so the exception is accepted instead. Measured 2026-09-19: 114 of 2016 generated pairs — every listed member and both by-shape spellings, in three cased spellings, against sixteen heads and six clause bodies of NAME WORDS ONLY — against 186 allowlisted and 984 disagreeing outside the class before the change, and 0 outside it after. The PR review widened that grid to three markers (`nee`, `née`, `geb.`) and three policies (the default and each 2.4 switch), and the class is INDIFFERENT to both: 1,026 of 18,144, exactly 9x the original in both columns, and still 0 disagreeing outside it. `tests/v2/test_properties.py` carries the sweep with the class defined STRUCTURALLY (`one_case` true of the clause form and false of the clause-less one) rather than as a name list. The recorded control beside it is now the SET and not only its size, as a digest over the members: a count cannot notice a swap, one pair leaving and another arriving, which is the same blindness the count was added to close one level up. diff --git a/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md b/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md index e1526d60..00e5e870 100644 --- a/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md +++ b/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md @@ -3,7 +3,7 @@ - 2026-09-14 #289 + #516 (this PR) — A BARE AMBIGUOUS ACRONYM IS READ BY THE EVIDENCE THE WRITING CARRIES, and written case is evidence the words-to-spare count does not have. In a name written in more than one case, a member of `suffix_acronyms_ambiguous` written in CAPITALS leans credential and is taken as a suffix with nothing to spare (`Jack MA` → given 'Jack', suffix 'MA', as `Jack MD` has always read); one written in any other cased form that is not wholly lower leans SURNAME and stays a name word with plenty to spare (`John Smith Ma` → middle 'Smith', family 'Ma'). An all-lower member carries no lean, a name written wholly in one case carries none at all, and both take the positional rule unchanged — `JOHN SMITH MA` suffix, `ANH DO` family, `john smith ma` suffix. Switch-free and scoped to the listed set: `ba do ed jd ma`. THE PREDICATE KEEPS THE JUDGED TOKEN IN THE SPAN, and that is what gives the right answers here. `JOHN SMITH Ma` is MIXED because of `Ma`, and a Title-case word against all-caps neighbours is exactly the surname signal this design wants; excluding the judged token would make the name one-case and hand `Ma` to the positional rule. Same for `Jack MA` read the other way — included, the name is mixed and the capitals lean credential; excluded, `own == ['Jack']` is one case and the peel keeps the family. The general question — whether a cased suffix token counts as case evidence — is #492's and stays deferred; this entry records that the INCLUDING reading is what this design rests on. - 2026-09-14 (Derek) — THE LEAN REACHES THE POST-COMMA GIVEN SLOT: `Smith, MA` → family 'Smith', suffix 'MA', while `Smith, Ma` and `Smith, ma` stay given. Positive evidence outranks position there, so the credential reading fires with ONE word before the comma where the positional rule would not. - AND THE COUNT AT A COMMA IS OF NAME WORDS. For this class, "words to spare" in `X, Y` form means the part before the comma holds two or more NAME words: `Smith Jr., MA` has two tokens and one name word, and a token count hands its family to `given` (measured on the prototype). rules/C1 already said both things — "more than one word precedes the comma" in its opening clause and "a part before the comma with more than one name word" later — so the doc edit is a reconciliation inside C1 rather than a new claim. + AND THE COUNT AT A COMMA IS OF NAME WORDS. For this class, "words to spare" in `X, Y` form means the part before the comma holds two or more NAME words: `Smith Jr., MA` has two tokens and one name word, and a token count hands its family to `given` (measured on the prototype). rules/C1 (the second clause rules/C8's since #728) already said both things — "more than one word precedes the comma" in its opening clause and "a part before the comma with more than one name word" later — so the doc edit is a reconciliation inside C1 rather than a new claim. - 2026-09-17, corrected — A CASELESS SCRIPT IS INERT ONLY AS A LEAN, NOT AS THE NAME-WORD COUNT. `is_one_case` answers True trivially for text with no case to write a contrast in, so `ambiguous_lean` returns `None` for `毛泽东, MA`, `마틴 킹, MA` and `田中 太郎, MA` alike — the LEAN really is inert by construction, as first written. But the comma count above is of NAME words "whatever case the name is written in", which is orthogonal to case and reaches a caseless script the same as any other: `마틴 킹, MA` and `田中 太郎, MA` each hold TWO pre-comma name words, so the structure flips and both read suffix 'MA' — a PARITY RESTORATION, not a fresh deviation (1.4.0 read both as suffix on the wheel), with the Hangul/Han surname split then running positionally over what the comma leaves (`마틴 킹` → given '틴', middle '킹', family '마'; `田中 太郎` → given '太郎', family '田中'), unrelated to this design and #271/#272's pre-existing behavior. `毛泽东, MA` does not move — one pre-comma token, the count never reaches two. Pinned `a_caseless_script_wrote_no_contrast_hangul` and `a_caseless_script_wrote_no_contrast_japanese` (tests/v2/cases.py), both `tolerated=True`. - 2026-09-14, corrected 2026-09-17 and 2026-09-18 — WHERE THE FACT LIVES, AND WHY THERE. The comma forms need the class decision at SEGMENT time, two stages before the own-words span is computable from marker TAGS. Four options were measured on a runtime probe (baseline 410.00 frames): segment computing it with a direct marker-head walk, +15; the same re-running the whole marker pass, +16; the fact staying in classify while classify RE-DECIDES `structure`, +0 but refused ON ORDERING — `structure` is read between segment and classify by `_script_segment.py`, which returns early on FAMILY_COMMA, so a flip decided in classify would leave the surname-split gate un-run on a name that turns out to have a suffix comma (measured: `Smith 김민준씨, MA` reads given 'Smith', middle '김', family '민준', suffix '씨, MA' on this tree — the figure first written here, given 'MA' with family 'Smith 김민준씨', was the reading before the flip moved to segment, re-measured 2026-09-18 — and a DIFFERENT wrong split again with the flip decided before script_segment — two stages disagreeing about one parse is what mechanisms.md#PIPELINE-STAGE-CONTRACTS exists to forbid); the marker pass and the fact MOVING to segment, +8, refused because killing the duplicated fold means carrying it on the state through CJK splits, a new bug surface in the one stage that changes token COUNT. SHIPPED: one shared helper, `_pieces.own_words(tokens, comma_offsets, markers, marker_tags=None)`, called by classify always (passing its own tag map) and by segment only when a comma form could turn on the fact. Every figure below is a TOTAL against `1f78befd` (the merge base this whole arc branched from), re-measured 2026-09-18 rather than accumulated per-commit: a `sys.setprofile` harness shaped like `tools/perf/call_count.py`'s `calls_for` (one warm-up parse, mean of 50, module-level `nameparser.parse`, run as a SCRIPT FILE outside either checkout so neither run's `sys.path[0]` masks the other — a `-c` one-liner instead would let the invoking shell's cwd shadow the older snapshot and read the current tree twice), against a `git archive 1f78befd nameparser` extraction on one side and this tree's own editable install on the other, same resolved interpreter both sides. Reference name (`tools/perf/call_count.py`'s own): 411 → 412 (+1). A comma-less, non-trigger control, `John Smith`: 175 → 176 (+1) — segment's lazy `own_words` call never fires without a comma, so this is classify's own always-on share of the cost. Comma names: a non-trigger, `Smith, John`: 203 → 207 (+4); `John Smith, Jr.`: 229 → 233 (+4); `Smith, John, Jr.`: 253 → 257 (+4) — every non-trigger comma name pays the SAME +4 total, dominated by classify's shared span cut plus the cheap case-free candidacy tests the three reading sites each add, not by the lazy fact itself (which these names never force). A genuine trigger, `Smith, MA`: 203 → 225 (+22) — the lazy fact plus its three readers' own work once a real candidate is found. (An earlier draft of this paragraph reported "+1 to +2 on non-trigger comma names" and "+19 to +20" on `Smith, MA`; those were PER-COMMIT deltas from the fix-round commentary above, accumulated inexactly rather than measured as one total, and are superseded by the totals here.) `own_words` shares its exact span-cutting walk with classify's own cut through a second shared function, `_vocab.tag_marker_runs` (moved out of `_classify.py` so `_pieces.py`'s own layering rule — it may import `_state` and `_vocab` only — is not broken): both call sites run the identical walk over the identical inputs, including `comma_offsets`, so a marker phrase like `z domu` is bucketed the same way at a comma either side. A corpus probe over the 1174 corpus entries confirms 0 cut differences and 0 verdict differences between the two call shapes (an earlier, text-only approximation that predated sharing `tag_marker_runs` differed on the CUT for 2 names — `Anna z Nowak`, `Anna z (domu) Nowak`, where `z` opens the phrase entry `z domu` but no run completes — while agreeing on the BOOLEAN for all 1174 even then). WITH SWITCH B ON, a genuine caps candidate pays more, recorded rather than chased to zero since nothing gates it — re-measured 2026-09-18 with `Parser().parse` on both sides of the on/off comparison (the convention `tests/v2/test_policy.py`'s own test uses), against this tree's own default: `Smith, John` 206 → 213 (+7), `Smith, XYZ` 205 → 245 (+40). Recompute (`tools/perf/call_count.py`'s own `calls_for` counts only its fixed reference name, so a chosen comma name needs the same shape spelled inline): `uv run python -c "import sys; from nameparser import Parser, Policy def calls_for(fn, name, n=50): diff --git a/docs/design/decisions/v1-xfail-triage.md b/docs/design/decisions/v1-xfail-triage.md index 0b7077a3..6f60a380 100644 --- a/docs/design/decisions/v1-xfail-triage.md +++ b/docs/design/decisions/v1-xfail-triage.md @@ -5,7 +5,7 @@ The reconciled v1-style banks (`tests/test_*.py`) carried eight `@pytest.mark.xf uv run python -c "from nameparser import HumanName; [print(repr(s), repr(HumanName(s))) for s in ('Maier, Amy I, Jr.', 'Dr King Jr', 'King, Dr Jr', 'Ahmad ben Husain', \"The Right Hon. the President of the Queen's Bench Division\")]" - 2026-09-01 (Derek's triage) — NOT FIXED, four. The aspiration is rejected, the marker is gone, and the test now pins what the parser does with the grounds recorded beside it so nobody re-derives the rejected proposal: - - `Maier, Amy I, Jr.` — v1 wanted suffix "I, Jr."; it reads given Amy, middle I, family Maier, suffix "Jr.". A middle initial `I` is far more common than an ordinal I borne without a Sr./Jr.-style companion, so where an explicit suffix comma has already named the suffix, the trailing I stays a middle initial. That is #vocabulary-collisions' commonality reasoning applied to a SHAPE rather than to a word — the criterion is written per-word, and the extension is named here rather than smuggled in. **This amends rules/C1 in the same diff.** C1's one-character clause said a bare single letter behind a name word is the generation, full stop, which predicts exactly the suffix "I, Jr." being rejected here. The discriminator the parser has always applied and C1 omitted is the FURTHER COMMA: `Maier, Amy I` reads suffix 'I' while `Maier, Amy I, Jr.` reads middle 'I', and `Smith, John V` against `Smith, John V, Jr.` is the same pair on a name C1 already exemplifies — all four measured. So the rule was incomplete, not the behavior: a rules.md defect rather than a parser one, which is the call this file already made for `'Donald mc'` in the fields-only arc entry (2026-08-27, #451). The qualifier is normative prose carrying no example line of its own; C1's Accepted block (rules/C9's since #728) records why and names the bank pair as its executable witness. + - `Maier, Amy I, Jr.` — v1 wanted suffix "I, Jr."; it reads given Amy, middle I, family Maier, suffix "Jr.". A middle initial `I` is far more common than an ordinal I borne without a Sr./Jr.-style companion, so where an explicit suffix comma has already named the suffix, the trailing I stays a middle initial. That is #vocabulary-collisions' commonality reasoning applied to a SHAPE rather than to a word — the criterion is written per-word, and the extension is named here rather than smuggled in. **This amends rules/C1 (rules/C9 since #728) in the same diff.** C1's one-character clause said a bare single letter behind a name word is the generation, full stop, which predicts exactly the suffix "I, Jr." being rejected here. The discriminator the parser has always applied and C1 omitted is the FURTHER COMMA: `Maier, Amy I` reads suffix 'I' while `Maier, Amy I, Jr.` reads middle 'I', and `Smith, John V` against `Smith, John V, Jr.` is the same pair on a name C1 already exemplifies — all four measured. So the rule was incomplete, not the behavior: a rules.md defect rather than a parser one, which is the call this file already made for `'Donald mc'` in the fields-only arc entry (2026-08-27, #451). The qualifier is normative prose carrying no example line of its own; C1's Accepted block (rules/C9's since #728) records why and names the bank pair as its executable witness. - `Dr King Jr` — **FIXED 2026-09-08 (the #316/#489 bundle), and this is the one bullet of the four that moved out of NOT FIXED.** v1 wanted title 'Dr', family 'King', suffix 'Jr', and that is what the parse gives; the leading title peel now leaves a name word a suffix cannot be (#H3). The bullet is amended in place rather than moved, because the half it DECIDED is untouched and is still the reason the shape exists at all. What follows is the 2026-09-01 record, with the reading it describes corrected where the fix falsified it. It read title 'Dr King', family 'Jr' then. `king` stays in the titles vocabulary: it is there for the addressing forms ("King Charles"), and taking it out to serve the surname reading trades a common use for a rarer one, which is the direction #vocabulary-collisions cuts. TITLES has no ambiguous subset and no AmbiguityKind, so — as with MAIDEN_MARKERS and `roz` — the only two expressions of the criterion available here are ship and do not ship; #348 is the open work that would give this set a third answer. The comma format is the road to the surname reading, and is now pinned alongside rather than left as prose — `King, Dr Jr` reads title 'Dr', family 'King', suffix 'Jr'. The test cites [#27](https://github.com/derek73/python-nameparser/issues/27), which is closed; this is the decision it never got. Two halves, and only one was decided. DECIDED: `king` stays in TITLES, which is unchanged and is what still puts `King` inside the title run in the first place. RECORDED, not endorsed: what becomes of the leftover `Jr`. rules/S2 predicts suffix 'Jr' with an empty family — its Accepted clause consumes an unambiguous suffix even when nothing is left to be the family (`Smith Jr.` → family "") — but once the title chain had taken `Dr King`, H1 claimed the one remaining word and it read family 'Jr', suffix ''. `Dr Smith Jr` isolated the cause: family 'Smith', suffix 'Jr', exactly as S2 states. S2 carried a descriptive note saying so. This entry then said that a future change moving `Dr King Jr` toward S2's prediction is an IMPROVEMENT and updates the pin — that change is #H3's peel floor and it landed 2026-09-08. The reading is now title 'Dr', family 'King', suffix 'Jr': the run gives back its last word where everything behind it is post-nominal and that word is not itself suffix vocabulary, so `Dr King Jr` reads like `Dr Smith Jr` rather than against it, and S2's note is replaced by the argument it asked for. The pin in `tests/test_suffixes.py::test_king` moved with it, by decision rather than by drift, and `king` being title vocabulary now buys the row a `title-or-name` report (#H4) rather than a different reading. - `Ahmad ben Husain` — v1 wanted family "ben Husain"; it reads given Ahmad, middle ben, family Husain. Already decided in v0.2.5, when `ben` came out of the prefixes, and for the reason that still holds: `ben` collides with the given name Ben, in the position the particle claim would act on — `Ahmad Ben Husain` reads middle 'Ben' today, which is exactly the token a forward-joining particle claim would take. That is C-i's position test, and it keeps `ben` out. Recorded a second time as a standing keep-out in the particle-line Excluded block in decisions/vocabulary-exclusions, because that is where a wordlist sweep meets it: a keep-out that lives only in a triage entry is one the next Arabic/Hebrew patronymic-particle sweep never reads. Worth naming as a failure mode of its own — the marker was an aspiration that outlived its own resolution, and nothing in a bare xfail says which of the eight were like that. diff --git a/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md b/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md index 245f0d08..f27047e6 100644 --- a/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md +++ b/docs/design/rules/C10-declared-delimiter-parts-a-trailing-suffix-part.md @@ -28,5 +28,5 @@ C10. Rationale: a caller who declares a suffix delimiter means it to part reads suffix "RN - CRNA" where 2.0 through 2.3, like v1, read given "RN". "Smith, RN - CRNA" → suffix="RN - CRNA" - "Steven Hardman, RN - CRNA" → given="Steven" · boundary + "Smith, RN - CRNA" extra_suffix_delimiters-dash → suffix="RN - CRNA" · boundary history: decisions/C10 · interacts: C1, C2, C8, M2, T1 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py diff --git a/docs/design/rules/C11-what-the-comma-decision-reports.md b/docs/design/rules/C11-what-the-comma-decision-reports.md index a088b243..e7c4f053 100644 --- a/docs/design/rules/C11-what-the-comma-decision-reports.md +++ b/docs/design/rules/C11-what-the-comma-decision-reports.md @@ -32,6 +32,7 @@ C11. Rationale: the comma's reading is a call the parse made, and (P6) reports on its own. C2's comma-structure flag reports what the parse could not recognize, not a fork it called. "Doe, MA PhD" → ambiguities=("suffix-or-name",) + "John Smith, PhD Jones" → ambiguities=("suffix-or-name",) "John Smith, PhD DO DO" → ambiguities=() · boundary "JOHN SMITH, VD MA" → ambiguities=("suffix-or-name",) "John Smith, Jr vd" → ambiguities=() diff --git a/docs/design/rules/C3-counting-the-words-before-the-comma.md b/docs/design/rules/C3-counting-the-words-before-the-comma.md index a7ba1935..29ac1aa7 100644 --- a/docs/design/rules/C3-counting-the-words-before-the-comma.md +++ b/docs/design/rules/C3-counting-the-words-before-the-comma.md @@ -1,7 +1,7 @@ # C3 — counting the words before the comma C3. Rationale: the listing form puts a surname before the comma, - and a surname written in several words is one surname. + and a particle surname is one surname. Wherever the comma's decision (C1) counts the words before the comma, a particle run and the one name word it attaches to are one word, the reach diff --git a/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md b/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md index 9ad51cd4..a44003b1 100644 --- a/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md +++ b/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md @@ -1,8 +1,8 @@ # C4 — the ambiguous credential class at the comma -C4. Rationale: two name words before the comma are a whole name, - and they leave the part after it nothing to be but credentials, - however that part is written. +C4. Rationale: two name words before the comma are read as a + whole name, so for a word that is both a credential and a name + the count decides before the writing does. For the ambiguous credential class — a bare acronym the vocabulary marks as also an ordinary name, and a word admitted to the class by shape, which S3 defines and bounds — the diff --git a/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md b/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md index 8227eec6..337a8f5a 100644 --- a/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md +++ b/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md @@ -16,5 +16,5 @@ C7. Rationale: the comma's decision reads the part after it once, "John Smith, Mr. and Mrs." → title="Mr. and Mrs." "Smith, Chancellor of the Exchequer MP" → suffix="MP" no-boundary: the listing form's given part, which keeps every - join, is what P2's, P3's and P5's own examples parse. + join, is what P5's own example parses ('Salam, abd Allah'). history: decisions/C7 · interacts: C1, C2, C8, P2, P3, P5, P6 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/C8-listing-forms-part-after-the-comma.md b/docs/design/rules/C8-listing-forms-part-after-the-comma.md index 9b58ddb7..f2e486ec 100644 --- a/docs/design/rules/C8-listing-forms-part-after-the-comma.md +++ b/docs/design/rules/C8-listing-forms-part-after-the-comma.md @@ -38,9 +38,9 @@ C8. Rationale: a comma fixes a family boundary only where a name "Smith, Dr. Jr." → suffix="Jr." "John Smith, PhD Jones" → given="John" "John Smith, PhD Jones" → suffix="PhD Jones" - "John Smith, PhD Jones" → ambiguities=("suffix-or-name",) "Doe, PhD Jones" → suffix="PhD Jones" "John Smith, Ph. D. Jones" → suffix="Ph. D. Jones" + "Steven Hardman, RN - CRNA" → given="Steven" "John Smith, MD Jones" → given="Jones" · boundary "Smith, Ms Jane" → title="Ms" · boundary "John Smith, Mr." → given="John" @@ -53,4 +53,4 @@ C8. Rationale: a comma fixes a family boundary only where a name duals, and the slot is postnominal. "Smith, Ms." → suffix="Ms." "Smith, Ms. Jane" → title="Ms." - history: decisions/C8 · interacts: C1, C7, C9, C11, H1, H6, S8 · implemented: nameparser/_pipeline/_comma.py + history: decisions/C8 · interacts: C1, C7, C9, C11, H1, H2, H6, S8 · implemented: nameparser/_pipeline/_comma.py diff --git a/docs/design/rules/S6-case-signal-at-the-suffix-slot.md b/docs/design/rules/S6-case-signal-at-the-suffix-slot.md index ca4060b8..2d467be7 100644 --- a/docs/design/rules/S6-case-signal-at-the-suffix-slot.md +++ b/docs/design/rules/S6-case-signal-at-the-suffix-slot.md @@ -22,7 +22,7 @@ S6. Rationale: in a name written in more than one case, the ('Smith, MA MA' reads suffix 'MA MA'), and only a word of the class opening the part reports, as the first word after the comma ('John Smith, MA MA' reports on the first 'MA' alone, - where a counted flip reports over the whole part). At the + where a counted flip reports over the whole part; the reports are C11's). At the trailing slot of the given part the comma has already settled the count, so the writing is the only evidence there is (S5). "Jack MA" → suffix="MA" diff --git a/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md b/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md index 6707dcc9..f0209f22 100644 --- a/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md +++ b/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -11,7 +11,7 @@ S9. Rationale: no wordlist holds every credential, and a word's position of a mixed-case name and belonging to no wordlist, is the other. By default that second shape is admitted in one position only, the part after a comma with two or more name - words before it (C6): French and Korean records write the + words before it (C4, C6): French and Korean records write the SURNAME in capitals, and that convention puts the capitals at the end of a name or before a comma, never after a comma behind a full name. Everywhere else it is admitted only under the diff --git a/tests/v2/cases.py b/tests/v2/cases.py index df5dbfd1..03b917df 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -3633,7 +3633,7 @@ def _check_cjk_shape_purity(self) -> None: "all-caps words, and every token in the run must be a " "candidate for the run itself to be one. Since #564 the " "DEFAULT reads it the same way, the comma position being " - "on by default, and rules/C1's `deviates: #291` " + "on by default, and rules/C1's (now rules/C6's) `deviates: #291` " "marker came off with that change"), Case("the_caps_comma_multi_word_run_declines_at_one_word", "Smith, LEED AP", From f6b379eff0ff08fd762283699901e436876be7f5 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sun, 18 Oct 2026 21:12:31 -0700 Subject: [PATCH 4/4] Fix the PR review findings on the C1 split (#728) - cases.py: a note read "C11's exception" where it meant C11's report (as assign's twin comment already says); a note credited C1 for the all-suffix part C8 reads; the C10 note names its maiden-marker Accepted entry - _comma.py and test_properties.py name C10's core-is-a-word Accepted entry, since C10 has two - _group.py, test_group.py, _types.py and two case notes name C8 or C11 beside C1/C4/C5 where those state the clause - decisions/S9, S7 and suffix-acronym-collisions: pointers to the #544 run, the dual sentence and the all-caps examples name C4, C5 and C6 Co-Authored-By: Claude Opus 5.5 --- .../S7-company-of-an-unambiguous-credential.md | 8 ++++---- ...ted-words-that-join-the-ambiguous-class-by-shape.md | 8 ++++---- docs/design/decisions/suffix-acronym-collisions.md | 4 ++-- nameparser/_pipeline/_comma.py | 3 ++- nameparser/_pipeline/_group.py | 2 +- nameparser/_types.py | 2 +- tests/v2/cases.py | 10 +++++----- tests/v2/pipeline/test_group.py | 2 +- tests/v2/test_properties.py | 2 +- 9 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/design/decisions/S7-company-of-an-unambiguous-credential.md b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md index 95dbcd6c..c9eba830 100644 --- a/docs/design/decisions/S7-company-of-an-unambiguous-credential.md +++ b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md @@ -1,14 +1,14 @@ # S7 — the company of an unambiguous credential -- 2026-09-27 (Derek), #544 — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS: C1'S NAME-WORD COUNT READS A RUN AS IT READS ONE WORD, AN UNAMBIGUOUS CREDENTIAL IN FRONT OF A MEMBER SPEAKS FOR IT AT EVERY TRAILING SLOT, AND A LISTED MEMBER'S CHUNKED DOTTED SPELLING PASSES THE PERIOD GATE. Three readers never looked past the member itself: C1 flipped a part to the suffix comma by the name-word count only when it was ONE token, and the trailing readers — the no-comma peel, the comma part read wholly as credentials, the given part's trailing slot — met the member first, took its name lean and stopped, so the credential in front of it was never reached. #540 marked `meng` and `lac`, whose conventional spellings lean name, which turned the one-case-row cost decisions/S6's 2026-09-15 item (ii) accepted into the ordinary spelling of two degrees. DECIDED, ten forks, (9) and (10) on 2026-09-28. (1) THE WHOLE RUN AT C1: two or more words after the comma, every one suffix vocabulary or a class candidate, at least one a candidate, behind two or more name words, is the credential run — all-member runs (`John Smith, Ed Ma`) and a leading member (`John Smith, MEng PhD`) included; offered and declined were an anchored-only rule and the issue's own "last word ambiguous" wording, which contradicted its `Ed Ma` consequence. (2) ALL TRAILING PATHS, ANCHORED IN FRONT: a listed member with an unambiguous credential in front of it, through other members, reads as the credential on the no-comma peel, in the comma part read wholly as credentials and at the given slot; a credential BEHIND it anchors nothing (`Wang Ma PhD` keeps family 'Ma'). (3) THE CHUNKED DOTTED GATE: a listed member written in two or more period-closed letter chunks (`M.Eng.`, `L.Ac.`) passes as `M.A.` does; one trailing period (`Ma.`, `Ed.`) still does not. (4) SINGLE-LETTER ROMAN NUMERALS neither anchor nor count toward a C1 run, S3's single-character retirement being the precedent; they peel as before, and the anchor's half of the exclusion is a matter of their shape, boundary (d) below. (5) NO NEW REPORT WHERE THE WRITING SETTLED IT: a run whose every member is LISTED and leans credential — S2's lean, capitals in a mixed-case name — is not flipped, the family-comma path already reading it as the credential run with the fields and the report it always had (`John Smith, PhD MA`) — narrowed 2026-10-01 by decisions/C4's #562 entry: a part holding two particles side by side is read by the count instead, and reports; the alternative, flipping such a run and suppressing only the flip's report, removes reports the family-comma path makes today, `John Smith, MA Jr` losing the one it makes for 'MA'. Because the lean is the listed set's, a by-shape member in capitals never settles a run: `John Smith, X.Y.Z. MA` flips to suffix 'X.Y.Z. MA' and reports. (6) THE ANCHOR DOES NOT REACH ACROSS A MAIDEN CLAUSE: `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' where `Jane Doe Jr. Ma` reads suffix 'Jr. Ma' — rules/M2's Accepted boundary, and the M2 bullet of this date. (7) AN ANCHOR OVERRIDES P6 AT THE GIVEN SLOT: `doe, jane v phd do` reads suffix 'v phd do' where it read family 'do doe'; the bullet below amends #531's pairing. (8) THE DUAL EXCLUSION IS SCOPED TO THE GIVEN PART'S LEADING TITLE RUN, NOT THE C1 RUN: after a full name the dual counts as the suffix vocabulary it is, as the legacy disjunct already counted it (`John Smith, MS MA`, `John Smith, MD`), and that is what reads the issue's headline `Jane Doe, MS LAc` as suffix 'MS LAc'. (9) A MEMBER THE COMPANY DECIDES REPORTS WHERE IT WAS DECIDED, in a family comma's part read wholly as credentials as at the peel: each member whose own writing declined and which the credential in front made the credential reports `suffix-or-name` at that reading. `Smith, PhD Ma`, `Smith, PhD MEng` and `Smith, Ph. D. MEng` report the member; `Smith, Dr. PhD LAc` and `John Quincy Smith, Prof. PhD LAc` report 'LAc'; `john smith, v phd ma` reports 'ma'; `John Smith, PhD Ma Prof.`, which C1 does not flip for its trailing title, reports 'Ma' where its twin `John Smith, PhD Ma` reports C1's flip once over the part. A member its own capitals made the credential stays silent (`Smith, PhD MA`), and the part's first piece, which nothing stands in front of, is the first-slot report's word and never this one's. (10) A DUAL IN THE GIVEN PART'S LEADING TITLE RUN SILENCES THE WHOLE PART: refinement (b) below reads such a dual as a title that speaks for nothing, and in a part whose leading title run holds one no credential behind the dual speaks for a member of the ambiguous class either, so the company never reads such a part wholly as credentials: the titles give it its reading, the dual a title and the next word the given name, and behind that given name the given slot reads as it reads any (`Smith, MD PhD Jr Ma` reads suffix 'Jr Ma', as `Smith, MD Jane Jr Ma` does). A part with no member to speak for, or only members whose capitals decide them, reads as it did: `Smith, MD PhD` suffix 'MD PhD', `Smith, Ms MD MA` suffix 'Ms MD MA'. `Smith, Ms MD Ma`, `Smith, MD MS Ma` and `SMITH, MD MS BA` read title 'Ms MD' / 'MD MS', given 'Ma' / 'BA'; `Smith, MD PhD Ma` reads title 'MD', given 'PhD', middle 'Ma', the given slot reporting 'Ma'; e10e83b4 read each of them so. The run is the walk's leading title run, so a dual behind a plain title stands in it too (`Smith, Prof. MD Ma` and `smith, prof. md ma` read title 'Prof. MD', given 'Ma'; `Smith, Dr. MD PhD Ma` keeps given 'PhD'), while a plain title alone silences nothing (`Smith, Dr. PhD LAc` reads title 'Dr.', suffix 'PhD LAc'). Behind a real given name the company reads as ever (`Doe, Jane MD Ma` and `Doe, Jane Ms Ma` read suffix 'MD Ma' and 'Ms Ma'), and past the title run a dual speaks like any suffix word (`Smith, PhD Ms Ma` reads suffix 'PhD Ms Ma'); `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` are unchanged. +- 2026-09-27 (Derek), #544 — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS: C1'S NAME-WORD COUNT READS A RUN (rules/C4 since #728) AS IT READS ONE WORD, AN UNAMBIGUOUS CREDENTIAL IN FRONT OF A MEMBER SPEAKS FOR IT AT EVERY TRAILING SLOT, AND A LISTED MEMBER'S CHUNKED DOTTED SPELLING PASSES THE PERIOD GATE. Three readers never looked past the member itself: C1 flipped a part to the suffix comma by the name-word count only when it was ONE token, and the trailing readers — the no-comma peel, the comma part read wholly as credentials, the given part's trailing slot — met the member first, took its name lean and stopped, so the credential in front of it was never reached. #540 marked `meng` and `lac`, whose conventional spellings lean name, which turned the one-case-row cost decisions/S6's 2026-09-15 item (ii) accepted into the ordinary spelling of two degrees. DECIDED, ten forks, (9) and (10) on 2026-09-28. (1) THE WHOLE RUN AT C1: two or more words after the comma, every one suffix vocabulary or a class candidate, at least one a candidate, behind two or more name words, is the credential run — all-member runs (`John Smith, Ed Ma`) and a leading member (`John Smith, MEng PhD`) included; offered and declined were an anchored-only rule and the issue's own "last word ambiguous" wording, which contradicted its `Ed Ma` consequence. (2) ALL TRAILING PATHS, ANCHORED IN FRONT: a listed member with an unambiguous credential in front of it, through other members, reads as the credential on the no-comma peel, in the comma part read wholly as credentials and at the given slot; a credential BEHIND it anchors nothing (`Wang Ma PhD` keeps family 'Ma'). (3) THE CHUNKED DOTTED GATE: a listed member written in two or more period-closed letter chunks (`M.Eng.`, `L.Ac.`) passes as `M.A.` does; one trailing period (`Ma.`, `Ed.`) still does not. (4) SINGLE-LETTER ROMAN NUMERALS neither anchor nor count toward a C1 run, S3's single-character retirement being the precedent; they peel as before, and the anchor's half of the exclusion is a matter of their shape, boundary (d) below. (5) NO NEW REPORT WHERE THE WRITING SETTLED IT: a run whose every member is LISTED and leans credential — S2's lean, capitals in a mixed-case name — is not flipped, the family-comma path already reading it as the credential run with the fields and the report it always had (`John Smith, PhD MA`) — narrowed 2026-10-01 by decisions/C4's #562 entry: a part holding two particles side by side is read by the count instead, and reports; the alternative, flipping such a run and suppressing only the flip's report, removes reports the family-comma path makes today, `John Smith, MA Jr` losing the one it makes for 'MA'. Because the lean is the listed set's, a by-shape member in capitals never settles a run: `John Smith, X.Y.Z. MA` flips to suffix 'X.Y.Z. MA' and reports. (6) THE ANCHOR DOES NOT REACH ACROSS A MAIDEN CLAUSE: `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' where `Jane Doe Jr. Ma` reads suffix 'Jr. Ma' — rules/M2's Accepted boundary, and the M2 bullet of this date. (7) AN ANCHOR OVERRIDES P6 AT THE GIVEN SLOT: `doe, jane v phd do` reads suffix 'v phd do' where it read family 'do doe'; the bullet below amends #531's pairing. (8) THE DUAL EXCLUSION IS SCOPED TO THE GIVEN PART'S LEADING TITLE RUN, NOT THE C1 RUN: after a full name the dual counts as the suffix vocabulary it is, as the legacy disjunct already counted it (`John Smith, MS MA`, `John Smith, MD`), and that is what reads the issue's headline `Jane Doe, MS LAc` as suffix 'MS LAc'. (9) A MEMBER THE COMPANY DECIDES REPORTS WHERE IT WAS DECIDED, in a family comma's part read wholly as credentials as at the peel: each member whose own writing declined and which the credential in front made the credential reports `suffix-or-name` at that reading. `Smith, PhD Ma`, `Smith, PhD MEng` and `Smith, Ph. D. MEng` report the member; `Smith, Dr. PhD LAc` and `John Quincy Smith, Prof. PhD LAc` report 'LAc'; `john smith, v phd ma` reports 'ma'; `John Smith, PhD Ma Prof.`, which C1 does not flip for its trailing title, reports 'Ma' where its twin `John Smith, PhD Ma` reports C1's flip once over the part. A member its own capitals made the credential stays silent (`Smith, PhD MA`), and the part's first piece, which nothing stands in front of, is the first-slot report's word and never this one's. (10) A DUAL IN THE GIVEN PART'S LEADING TITLE RUN SILENCES THE WHOLE PART: refinement (b) below reads such a dual as a title that speaks for nothing, and in a part whose leading title run holds one no credential behind the dual speaks for a member of the ambiguous class either, so the company never reads such a part wholly as credentials: the titles give it its reading, the dual a title and the next word the given name, and behind that given name the given slot reads as it reads any (`Smith, MD PhD Jr Ma` reads suffix 'Jr Ma', as `Smith, MD Jane Jr Ma` does). A part with no member to speak for, or only members whose capitals decide them, reads as it did: `Smith, MD PhD` suffix 'MD PhD', `Smith, Ms MD MA` suffix 'Ms MD MA'. `Smith, Ms MD Ma`, `Smith, MD MS Ma` and `SMITH, MD MS BA` read title 'Ms MD' / 'MD MS', given 'Ma' / 'BA'; `Smith, MD PhD Ma` reads title 'MD', given 'PhD', middle 'Ma', the given slot reporting 'Ma'; e10e83b4 read each of them so. The run is the walk's leading title run, so a dual behind a plain title stands in it too (`Smith, Prof. MD Ma` and `smith, prof. md ma` read title 'Prof. MD', given 'Ma'; `Smith, Dr. MD PhD Ma` keeps given 'PhD'), while a plain title alone silences nothing (`Smith, Dr. PhD LAc` reads title 'Dr.', suffix 'PhD LAc'). Behind a real given name the company reads as ever (`Doe, Jane MD Ma` and `Doe, Jane Ms Ma` read suffix 'MD Ma' and 'Ms Ma'), and past the title run a dual speaks like any suffix word (`Smith, PhD Ms Ma` reads suffix 'PhD Ms Ma'); `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` are unchanged. WHAT MAY ANCHOR, four boundaries of the company clause, each a rule. (a) A CONNECTIVE (P3) anchors nothing and ends the run: the generational `i` is also Catalan's conjunction, and `Jane Doe nee Puig i Ma` keeps its link, maiden 'Puig i Ma'. (b) A TITLE/SUFFIX DUAL IN THE GIVEN PART'S LEADING TITLE RUN after a one-word family comma is a title there and anchors nothing — and, by (10), no credential behind it speaks for a member in that part — so the given slot's pass starts past the leading title run: `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` keep their given names, and `Smith, MD MA Ma` keeps middle 'Ma', no unambiguous credential standing in its run once the title is set aside. Everywhere else a dual anchors like any suffix word (`John Smith MD MEng`, and (8)). (c) THE WALK'S OWN LEADING PIECE NEVER ANCHORS. In a name with no comma before the run, the leading piece is the name word H4's carve-out keeps whatever its vocabulary, and letting it anchor spends that name: in `Om Ma` and `PhD Ma` 'Ma' stays a name word under every name order (family 'Ma' in the default order, given 'Ma' under both family-first orders), where an anchoring `PhD` would leave given 'PhD', suffix 'Ma' and no family at all. One name word in front frees the same word — `John Om Ma` reads suffix 'Om Ma'. A family comma's given part has no such position, its first piece being read by the comma part's own credential reading, so `Smith, PhD MEng` reads family 'Smith', suffix 'PhD MEng'. (d) A SINGLE-LETTER ROMAN NUMERAL, IN ANY CASE (`V`, `v`, `I.`), anchors nothing for its SHAPE — one letter is how a middle initial is written, and S3 retired single-character vocabulary matches for the same reason (`John Smith PhD V Ma` keeps family 'Ma') — not because a generation is no credential. The rule is stated by that shape and the code tests exactly it (`_vocab.is_single_letter_numeral`); "initial-shaped" was the wrong word for it, `is_initial_shaped` answering False for a lower-case `v` that the exclusion covers too. And the exclusion is not about generations: a multi-letter numeral or generational word anchors as any suffix word does (`John Smith PhD III Ma` reads suffix 'PhD III Ma', `abdul Smith Jr Ma` suffix 'Jr Ma'). ACCEPTED COSTS, each a reading this design chooses. `John Smith, Ms Ma` → suffix 'Ms Ma', reported, as `John Smith, Ms` alone already reads suffix (8). `Om Jr Ma` → given 'Om', suffix 'Jr Ma' and no family name: 'Jr' is not the leading piece, so it anchors 'Ma', and rules/S2's standing Accepted clause already consumes an unambiguous suffix that leaves no family (`Om Jr` → given 'Om', suffix 'Jr'); every release from 2.0.0 made that same role assignment. `doe, jane v phd do` reports `suffix-or-name` where it reported `particle-or-given`, the fork reported being the credential pick rather than P6's attachment (7). And a one-word family comma whose given part OPENS with a capitalised `MA` in a mixed-case name, with an unambiguous credential behind it, reads the whole part as a credential run, family 'Smith' and no given name, where the parent kept given 'MA': `Smith, MA PhD Ma` read given 'MA', middle 'Ma', suffix 'PhD' and reads suffix 'MA PhD Ma'. 'MA' leans credential on its capitals, so it is read as the credential it is written as, and what follows it is then the company's (or, for `M.Eng.`, the chunked gate's). Measured 2026-09-28 on the grid below: 138 parses (46 texts, three orders each) go from given 'MA' to no given name, 72 of them with no `M.Eng.` in the run; 12 of the 138 are the parses the attribution below leaves unattributed, the rest falling in the anchor and `M.Eng.` classes. Each reports 'MA' at the first slot, and a member behind the credential reports as the company's pick (9). ACCEPTED LIMITS, each keeping the member a name word, rules/S2's Accepted block and case rows: the merged `Ph. D.` is outside the no-comma walk (`John Smith Ph. D. MEng` family 'MEng'; the comma and given-slot spellings do anchor it, `Smith, Ph. D. MEng` and `Doe, Jane Ph. D. MEng` reading suffix 'Ph. D. MEng'), a title between the credential and the member breaks the run (`John Smith PhD Prof. Ma`), and a particle member P2 has chained before the peel is out of reach (`John Smith PhD Do Do` family 'Do Do'; `Smith, PhD Do Ma` given 'PhD', middle 'Do Ma'). THE TWO-INPUT CHECK. tests/v2/test_properties.py's M2 clause-agreement walk asks every parse it takes both directions of the company clause: a listed member behind a qualifying credential reads as one, and a member read as a credential because of the word in front has that word in the suffix too. Recorded negative controls, from that test's docstring: 48 failing parses with the anchor off, 30 with the walk's leading piece allowed to anchor, 0 on this tree; re-run 2026-09-28 after (9) and (10), unmoved. The walk holds no comma head that opens its part with a dual, so (10) is pinned by case rows and a unit test instead (`a_dual_opening_the_given_part_turns_the_anchor_off`, `a_dual_opening_the_given_part_silences_a_later_degree`, test_pieces' `test_a_dual_in_the_leading_title_run_turns_the_anchor_off`). - REVERSED BY THIS ENTRY rather than edited: decisions/S6's 2026-09-15 ACCEPTED item (ii) (the bullet after this entry says so); the three "wrongly moved" reversals decisions/S9's 2026-09-14 paragraph THE RUN IS THE CAPS CLASS'S ALONE records — `John Smith, Ed Ma`, `John Smith, ma do` and `John Smith, X.Y.Z. A.B.` are credential runs now by C1's own count under every policy that keeps their words in the class (with `unlisted_dotted_suffixes` off, `X.Y.Z.` and `A.B.` are no class members and the third keeps the listing form), while that paragraph's narrowing of the CAPS run test stands; and #540's two pending questions (the `suffix-acronym-collisions` bullet of this date). - MEASURED 2026-09-28, the #544 tree against its parent e10e83b4, py3.11, after (9) and (10); the 2026-09-27 figures this paragraph first carried were taken before them and are superseded. Recompute: every run of one to three words from {PhD, MD, Jr, MA, Ma, MEng, M.Eng., LAc, Ed, Do, ba, MS, V, Prof.} behind each of `Wang {}`, `John Smith {}`, `John Quincy Smith {}`, `Dr. John Smith {}`, `John Smith, {}`, `John Quincy Smith, {}`, `Smith, {}`, `Doe, Jane {}`, `Jane Doe nee Smith {}`, `Doe, Jane nee Smith {}` and `Jan van der Berg {}`, each written as given, lower-cased and upper-cased and deduped, parsed under the three name orders at otherwise default policy, the seven role fields and the ambiguity kinds compared against the same grid parsed by e10e83b4. THE DETECTOR, which both the invariants and the attribution read, looks only at the run's own words, compared case-free: {phd, md, jr, ms, m.eng.} are the unambiguous credentials, {ma, meng, lac, ed, do, ba} the members; a member is ANCHORED where an unambiguous credential stands in front of it in the run with nothing but members between, except that `ms` or `md` opening a comma part anchors nothing; and a run word is IN A NAME FIELD where it appears among the space-split words of given, middle, family or maiden more often than the run's unconstrained occurrences of it account for. The two invariants count parses with an anchored member in a name field, and with an unambiguous credential in one. THE ATTRIBUTION puts each moved parse in ONE class, the first that holds, in this order: THE ANCHOR, where the tree's two violation counts are each no higher than the parent's and one is lower; THE CHUNKED `M.Eng.`, where the run holds `M.Eng.`; A LONE `v`, where re-running the first test with `v` counted as an unambiguous credential makes it hold; THE C1 RUN, a comma shape with two or more name words before the comma whose run is only credentials and members, at least one a member; and anything left unattributed. 254,496 parses over 84,832 texts; 89,825 move (29,943 texts, 19,123 of them with a field move): 39,261 the anchor, 14,676 the C1 run, 35,696 the chunked `M.Eng.` (32,472 of those only losing the report it raised as a pick), 180 a lone `v` (`john smith, v phd ma`, suffix 'v phd ma'), and 12 unattributed, the `Smith, MA PhD ba` parses above. Clean-to-clean — no run word in a name field on either side — 23,454 move, every one `M.Eng.`'s dropped report, and none elsewhere. The two invariants, 0 NEW violations of either: an anchored member in a name field, 29,199 → 1,530 (the residue is a particle member P2 has chained, a dual inside a leading title run, `smith, prof. md ma`, and the member behind a credential in a part a dual opens, `Smith, MD PhD Ma`, which (10) keeps a name); an unambiguous credential in a name field, 36,675 → 4,734 (the residue is an interior single-letter `V`, a non-final `Prof.`, the `PhD` a comma part keeps as its given name when a `V` or a title follows it, and the same `PhD` behind a dual that opens the part, (10) again). Against the tree before (9) and (10), the same grid moves 5,076 parses: 4,536 (1,512 texts, every one a comma shape whose part reads wholly as credentials) gain a `suffix-or-name` report and move nothing else, and 540 (180 texts, every one `Smith, ` then `MD` or `MS` opening the run, in each case form) return to e10e83b4's reading, fields and reports alike, from a silent suffix reading of the whole part. The differential corpora at the parent (1,418 names, three orders) move four names, every one intended: `John Smith, PhD MEng`, `john smith, phd meng`, `Wang M.Eng.`, `abdul Smith Jr Ma`. Frames per parse through `Parser().parse`, py3.11: the reference band is unmoved (370/407, `uv run python tools/perf/call_count.py`), and so is every ordinary name measured (`Smith, John Quincy` 260, `Doe, John MA` 283, `Doe, Jane nee Smith PhD` 338) except a comma name with two or more words on each side of the comma, which enters the C1 run test and pays `run_word_fold`'s one call before its first name word ends it (`Doe Smith, Jane Q.` 317 → 318, `Garcia Lopez, Maria Jose` 330 → 331, measured 2026-09-28); the new questions cost where they are asked — `John Smith Ma` 244 → 250, the anchor pass that finds nothing; `John Smith, MA Jr` 355 → 382, the case fact forced to see the capitals settle the run; `John Smith PhD MEng` 286 → 326; `Smith, MD PhD Ma` 349 → 362, the given slot's pass that (10) leaves with nothing to find — and `John Smith, PhD MEng` gets cheaper, 358 → 314, as does `Smith, PhD Ma`, 286 → 271. + REVERSED BY THIS ENTRY rather than edited: decisions/S6's 2026-09-15 ACCEPTED item (ii) (the bullet after this entry says so); the three "wrongly moved" reversals decisions/S9's 2026-09-14 paragraph THE RUN IS THE CAPS CLASS'S ALONE records — `John Smith, Ed Ma`, `John Smith, ma do` and `John Smith, X.Y.Z. A.B.` are credential runs now by C1's own count (rules/C4 since #728) under every policy that keeps their words in the class (with `unlisted_dotted_suffixes` off, `X.Y.Z.` and `A.B.` are no class members and the third keeps the listing form), while that paragraph's narrowing of the CAPS run test stands; and #540's two pending questions (the `suffix-acronym-collisions` bullet of this date). + MEASURED 2026-09-28, the #544 tree against its parent e10e83b4, py3.11, after (9) and (10); the 2026-09-27 figures this paragraph first carried were taken before them and are superseded. Recompute: every run of one to three words from {PhD, MD, Jr, MA, Ma, MEng, M.Eng., LAc, Ed, Do, ba, MS, V, Prof.} behind each of `Wang {}`, `John Smith {}`, `John Quincy Smith {}`, `Dr. John Smith {}`, `John Smith, {}`, `John Quincy Smith, {}`, `Smith, {}`, `Doe, Jane {}`, `Jane Doe nee Smith {}`, `Doe, Jane nee Smith {}` and `Jan van der Berg {}`, each written as given, lower-cased and upper-cased and deduped, parsed under the three name orders at otherwise default policy, the seven role fields and the ambiguity kinds compared against the same grid parsed by e10e83b4. THE DETECTOR, which both the invariants and the attribution read, looks only at the run's own words, compared case-free: {phd, md, jr, ms, m.eng.} are the unambiguous credentials, {ma, meng, lac, ed, do, ba} the members; a member is ANCHORED where an unambiguous credential stands in front of it in the run with nothing but members between, except that `ms` or `md` opening a comma part anchors nothing; and a run word is IN A NAME FIELD where it appears among the space-split words of given, middle, family or maiden more often than the run's unconstrained occurrences of it account for. The two invariants count parses with an anchored member in a name field, and with an unambiguous credential in one. THE ATTRIBUTION puts each moved parse in ONE class, the first that holds, in this order: THE ANCHOR, where the tree's two violation counts are each no higher than the parent's and one is lower; THE CHUNKED `M.Eng.`, where the run holds `M.Eng.`; A LONE `v`, where re-running the first test with `v` counted as an unambiguous credential makes it hold; THE C1 RUN (rules/C4 since #728), a comma shape with two or more name words before the comma whose run is only credentials and members, at least one a member; and anything left unattributed. 254,496 parses over 84,832 texts; 89,825 move (29,943 texts, 19,123 of them with a field move): 39,261 the anchor, 14,676 the C1 run, 35,696 the chunked `M.Eng.` (32,472 of those only losing the report it raised as a pick), 180 a lone `v` (`john smith, v phd ma`, suffix 'v phd ma'), and 12 unattributed, the `Smith, MA PhD ba` parses above. Clean-to-clean — no run word in a name field on either side — 23,454 move, every one `M.Eng.`'s dropped report, and none elsewhere. The two invariants, 0 NEW violations of either: an anchored member in a name field, 29,199 → 1,530 (the residue is a particle member P2 has chained, a dual inside a leading title run, `smith, prof. md ma`, and the member behind a credential in a part a dual opens, `Smith, MD PhD Ma`, which (10) keeps a name); an unambiguous credential in a name field, 36,675 → 4,734 (the residue is an interior single-letter `V`, a non-final `Prof.`, the `PhD` a comma part keeps as its given name when a `V` or a title follows it, and the same `PhD` behind a dual that opens the part, (10) again). Against the tree before (9) and (10), the same grid moves 5,076 parses: 4,536 (1,512 texts, every one a comma shape whose part reads wholly as credentials) gain a `suffix-or-name` report and move nothing else, and 540 (180 texts, every one `Smith, ` then `MD` or `MS` opening the run, in each case form) return to e10e83b4's reading, fields and reports alike, from a silent suffix reading of the whole part. The differential corpora at the parent (1,418 names, three orders) move four names, every one intended: `John Smith, PhD MEng`, `john smith, phd meng`, `Wang M.Eng.`, `abdul Smith Jr Ma`. Frames per parse through `Parser().parse`, py3.11: the reference band is unmoved (370/407, `uv run python tools/perf/call_count.py`), and so is every ordinary name measured (`Smith, John Quincy` 260, `Doe, John MA` 283, `Doe, Jane nee Smith PhD` 338) except a comma name with two or more words on each side of the comma, which enters the C1 run test and pays `run_word_fold`'s one call before its first name word ends it (`Doe Smith, Jane Q.` 317 → 318, `Garcia Lopez, Maria Jose` 330 → 331, measured 2026-09-28); the new questions cost where they are asked — `John Smith Ma` 244 → 250, the anchor pass that finds nothing; `John Smith, MA Jr` 355 → 382, the case fact forced to see the capitals settle the run; `John Smith PhD MEng` 286 → 326; `Smith, MD PhD Ma` 349 → 362, the given slot's pass that (10) leaves with nothing to find — and `John Smith, PhD MEng` gets cheaper, 358 → 314, as does `Smith, PhD Ma`, 286 → 271. NO MECHANISMS ENTRY IS OWED: the anchor pass is ONE-PREDICATE-PER-QUESTION's `_pieces.credential_anchors`, which `segment_suffix_reading` reads inline for the frame budget under a keep-in-step note, and its linearity is the answer #531's trailing floor and #397's `_run_neighbours` already give — one forward pass per question, never a look-behind per member. tests/v2/test_benchmark.py's `credential_run` shape guards it: the per-member look-behind measured 15.8× for 4× the input at base 800, against 4.13–4.21× on this tree at every base (that module's own record). - ADDENDUM 2026-09-28 (Derek). (e) A PARTICLE ANCHORS NOTHING, a fifth boundary beside the four WHAT MAY ANCHOR lists: a word of both the particle and the unambiguous suffix vocabulary (`vd` and `mc` in the shipped lexicon; `do` sits in the ambiguous half, so it is a member and never an anchor) heads the family name behind it (P2), and anchoring there split the family around a suffix — `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd'; `Jan vd Ma` suffix 'vd Ma' and no family; `Smith Mc Ma, John` and `D. Mc Ba Ed, Smith` the same way. Each reads as e10e83b4 reads it again (family 'Smith vd Ma', 'vd Ma', 'Smith Mc Ma', 'D. Mc Ba Ed'). The exclusion is of the word in front only: a particle MEMBER behind a credential is still spoken for (`doe, jane v phd do` keeps suffix 'v phd do'), and a C1 run holding the particle is C1's reading rather than the anchor's (`Jan Berg, vd Ma` reads suffix 'vd Ma', as `Jan Berg, vd` reads suffix 'vd'). Measured over the grid of MEASURED above with `vd` and `Mc` added to its words (127,578 texts, 382,734 parses), against the tree before this addendum: 9,426 parses (3,142 texts) move, every one holding `vd` or `Mc` directly in front of a member; 8,940 return to e10e83b4's reading exactly, and the other 486 all hold `M.Eng.`, whose own change is (3) — 406 of them take e10e83b4's fields and differ only in the pick `M.Eng.` no longer reports, and 80 take the fields e10e83b4 gives the same text with `PhD` in `M.Eng.`'s place (`Jane Doe nee Smith M.Eng. vd Ma` reads middle 'Doe M.Eng.', family 'vd Ma', as `Jane Doe nee Smith PhD vd Ma` reads middle 'Doe PhD'). The differential corpora move 0 of 1441 names, and the grid of MEASURED without the two words moves 0 parses, so every figure there stands. THE PASS IS ASKED ONLY WHERE A SUFFIX PIECE IS IN REACH: `_pieces.anchor_in_reach` walks back through the lone members in front of a declined member on tags alone and answers False wherever the pass must — the first piece past the members is no suffix piece, or there is none — and it is asked only before the pass exists, one lookup answering after that, so a run of members stays linear. An ordinary name ending in a declined member stops paying for the pass (frames, py3.11, before → after, e10e83b4 in brackets): `John Smith Ma` 250 → 246 (244), `Smith, John Ma` 285 → 279 (275), `Doe, Jane MA do` 374 → 369 (363), `Doe, John Q. Ma` 328 → 320 (316), `Jan vd Ma` 308 → 243 (238); a name the pass does read pays the test on top, `John Smith PhD MEng` 326 → 329 and `Doe, Jane PhD MEng` 359 → 362. On the no-comma peel the reach starts past the walk's leading piece, which never anchors — so a by-shape member, which reaches the pass only at the peel's second position (a lean of None with a word to spare is consumed first), finds nothing in reach, and the by-shape guard that stood there was deleted as unreachable. `segment_suffix_reading` calls `credential_anchors` (`first_kept=False`, a comma part's first piece being a run member like any other) in place of the inline copy NO MECHANISMS describes, and the keep-in-step note goes with it. The pass is built only where the reach test finds a suffix piece in front of a declined member, which no member opening the part has, and a part whose name word stands ahead of any member returns before one is asked: a plain comma name pays nothing (`Smith, John` 182, `Doe, John MA` 283, `Smith, J. Q.` 246, `Smith, Ed` and `Smith, Ma` 213, `Smith, Ed John` 270, all as before the call replaced the copy; built on every declined member instead, the last three paid 215, 215 and 273), a title in front pays the reach test alone (`Smith, Dr. Ma` 253 → 254), and a part the company decides pays three frames over the copy (`Smith, PhD MEng` 273 → 276, `Smith, PhD Ma` 271 → 274; `Smith, PhD Ma John` 330 → 335, its pass built for 'Ma' before 'John' ends the part). THE TWO-INPUT CHECK's second control reads 42, not 30, with no change to the property: the walk's leading piece is now held out of the pass twice on the no-comma peel, by `credential_anchors` and by the reach test, either alone suffices, and with both removed the by-shape heads the deleted guard held back (`PhD X.Y.Z.`) fail beside the listed ones; the first control is unmoved at 48. And the 2026-09-28 C2 bullet's "a TAIL segment, which assign reads wholly as suffixes" holds outside a maiden clause standing in it — `Jane Doe, PhD, Jr nee van Ma` keeps maiden 'van Ma' — which rules/C2's statement now says. The reach test's one-way exactness is held by tests/v2/pipeline/test_pieces.py's `test_anchor_in_reach_never_hides_an_anchor`, over the case table's texts and runs of one to three words behind two heads; its recorded negative control, the reach test reading a `vocab:suffix` token as no suffix piece, fails at 878 member positions. + ADDENDUM 2026-09-28 (Derek). (e) A PARTICLE ANCHORS NOTHING, a fifth boundary beside the four WHAT MAY ANCHOR lists: a word of both the particle and the unambiguous suffix vocabulary (`vd` and `mc` in the shipped lexicon; `do` sits in the ambiguous half, so it is a member and never an anchor) heads the family name behind it (P2), and anchoring there split the family around a suffix — `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd'; `Jan vd Ma` suffix 'vd Ma' and no family; `Smith Mc Ma, John` and `D. Mc Ba Ed, Smith` the same way. Each reads as e10e83b4 reads it again (family 'Smith vd Ma', 'vd Ma', 'Smith Mc Ma', 'D. Mc Ba Ed'). The exclusion is of the word in front only: a particle MEMBER behind a credential is still spoken for (`doe, jane v phd do` keeps suffix 'v phd do'), and a C1 run (rules/C4 since #728) holding the particle is C1's reading rather than the anchor's (`Jan Berg, vd Ma` reads suffix 'vd Ma', as `Jan Berg, vd` reads suffix 'vd'). Measured over the grid of MEASURED above with `vd` and `Mc` added to its words (127,578 texts, 382,734 parses), against the tree before this addendum: 9,426 parses (3,142 texts) move, every one holding `vd` or `Mc` directly in front of a member; 8,940 return to e10e83b4's reading exactly, and the other 486 all hold `M.Eng.`, whose own change is (3) — 406 of them take e10e83b4's fields and differ only in the pick `M.Eng.` no longer reports, and 80 take the fields e10e83b4 gives the same text with `PhD` in `M.Eng.`'s place (`Jane Doe nee Smith M.Eng. vd Ma` reads middle 'Doe M.Eng.', family 'vd Ma', as `Jane Doe nee Smith PhD vd Ma` reads middle 'Doe PhD'). The differential corpora move 0 of 1441 names, and the grid of MEASURED without the two words moves 0 parses, so every figure there stands. THE PASS IS ASKED ONLY WHERE A SUFFIX PIECE IS IN REACH: `_pieces.anchor_in_reach` walks back through the lone members in front of a declined member on tags alone and answers False wherever the pass must — the first piece past the members is no suffix piece, or there is none — and it is asked only before the pass exists, one lookup answering after that, so a run of members stays linear. An ordinary name ending in a declined member stops paying for the pass (frames, py3.11, before → after, e10e83b4 in brackets): `John Smith Ma` 250 → 246 (244), `Smith, John Ma` 285 → 279 (275), `Doe, Jane MA do` 374 → 369 (363), `Doe, John Q. Ma` 328 → 320 (316), `Jan vd Ma` 308 → 243 (238); a name the pass does read pays the test on top, `John Smith PhD MEng` 326 → 329 and `Doe, Jane PhD MEng` 359 → 362. On the no-comma peel the reach starts past the walk's leading piece, which never anchors — so a by-shape member, which reaches the pass only at the peel's second position (a lean of None with a word to spare is consumed first), finds nothing in reach, and the by-shape guard that stood there was deleted as unreachable. `segment_suffix_reading` calls `credential_anchors` (`first_kept=False`, a comma part's first piece being a run member like any other) in place of the inline copy NO MECHANISMS describes, and the keep-in-step note goes with it. The pass is built only where the reach test finds a suffix piece in front of a declined member, which no member opening the part has, and a part whose name word stands ahead of any member returns before one is asked: a plain comma name pays nothing (`Smith, John` 182, `Doe, John MA` 283, `Smith, J. Q.` 246, `Smith, Ed` and `Smith, Ma` 213, `Smith, Ed John` 270, all as before the call replaced the copy; built on every declined member instead, the last three paid 215, 215 and 273), a title in front pays the reach test alone (`Smith, Dr. Ma` 253 → 254), and a part the company decides pays three frames over the copy (`Smith, PhD MEng` 273 → 276, `Smith, PhD Ma` 271 → 274; `Smith, PhD Ma John` 330 → 335, its pass built for 'Ma' before 'John' ends the part). THE TWO-INPUT CHECK's second control reads 42, not 30, with no change to the property: the walk's leading piece is now held out of the pass twice on the no-comma peel, by `credential_anchors` and by the reach test, either alone suffices, and with both removed the by-shape heads the deleted guard held back (`PhD X.Y.Z.`) fail beside the listed ones; the first control is unmoved at 48. And the 2026-09-28 C2 bullet's "a TAIL segment, which assign reads wholly as suffixes" holds outside a maiden clause standing in it — `Jane Doe, PhD, Jr nee van Ma` keeps maiden 'van Ma' — which rules/C2's statement now says. The reach test's one-way exactness is held by tests/v2/pipeline/test_pieces.py's `test_anchor_in_reach_never_hides_an_anchor`, over the case table's texts and runs of one to three words behind two heads; its recorded negative control, the reach test reading a `vocab:suffix` token as no suffix piece, fails at 878 member positions. - 2026-09-27 (#544) — THE 2026-09-15 ACCEPTED ITEM (ii) IS REVERSED, and the bullet stands as it landed. `abdul Smith Jr Ma` reads given 'abdul', family 'Smith', suffix 'Jr Ma' — 2.3.0's reading — because the unambiguous 'Jr' in front of the Title-case 'Ma' anchors it (the entry above): the peel takes both, and P5's reserve, now seeing the family the join would take, declines the join. Item (i) stands. The case row is now `a_credential_in_front_anchors_a_declined_pick`, and rules/S2's Accepted block names the shapes that still keep the company out of reach. - 2026-09-27 (Derek), #544 — THE #531 PAIRING GAINS A SECOND EXCEPTION, and decisions/S5's CAPITALS DECIDE FOR `do` stands as it landed. That bullet let only a positive credential lean override P6's attachment at the given slot; an unambiguous credential IN FRONT of the member now overrides it as well, the degree being a second and stronger signal: `doe, jane v phd do` reads suffix 'v phd do' and reports `suffix-or-name` where it read family 'do doe' and reported P6's fork. The one-case record the pairing protects has nothing in front of its particle, so `NASCIMENTO, EDSON ARANTES DO` still reads family 'DO NASCIMENTO' and reports `particle-or-given`. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md b/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md index 980e7008..3569f6d0 100644 --- a/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md +++ b/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -14,13 +14,13 @@ - 2026-09-18, corrected — `BUILD_RULES_CORPUS.PY` HARVESTING A POLICY-ANNOTATED-ONLY EXAMPLE IS LONG-STANDING, NOT NEW, AND THIS ARC ADDS TWO MORE INSTANCES OF IT. Its own docstring says "Policy annotations are deliberately ignored... a family-first-scoped example is simply one more name to diff" — an earlier draft of this bullet claimed that sentence was written when every annotated example had a default-policy sibling; measured false, at the commit that wrote it and on this tree alike. Recompute: parse every rule's examples with `tests/v2/rules_doc.py`'s own `parse_rules_doc`, group by `text` across the WHOLE document, and keep every text whose every appearance's annotation resolves (`resolve_annotation`) to kind `"policy"` rather than `"locale"`/`"gated_locale"` and never appears bare. That gives **19** pre-existing examples (`Mesnil de`, `Smith, de Mesnil Jean`, `de la Vega y Santos Juan`, `ibn Awf abdul Rahman`, `Nguyen Thi Van`, `Jane Smith (Nee)`, `Jane Smith (Nee) (Jones)`, `Garcia Juan Carlos`, `Nguyễn Thị Minh Khai`, `Del Toro`, `Anh Van Do`, `Beethoven Ludwig van`, `Berg Jan de Jr.`, `Garcia`, `Ménil Christophe de`, `Ménil de`, `Smith (Jones)`, `de Anke van y`, `van Berg Jan de`) plus this arc's own **2** (`Jean DUPONT`, `Jean Pierre DUPONT`, both `unlisted_caps_suffixes-on`, rules/S2) — **21** total on this tree. So the shape was accepted the day the docstring was written, not discovered here; what this arc adds is two more members, checked here for the reason the advice below names: `Jean Pierre DUPONT`'s DEFAULT reading (given Jean, middle Pierre, family DUPONT — measured; the annotated ON reading, given Jean, family Pierre, suffix DUPONT, is the only one rules.md states) never appeared in the doc before, and it produces no diff at any of the five baselines either way. Recorded per the same over-collection principle that keeps `(` and `.,` in the corpus (`tools/differential/README.md`'s provenance table): an annotated example's TEXT is a default-policy corpus name regardless of its annotation, full stop, and every one of the 21 — not only this PR's two — is a standing instance a reader should check for a default-policy diff on before assuming it free. - 2026-09-18 — THE COMMA HARNESS AND THE FRAME BAND, AFTER THE ROUND. Unchanged at the default: 412/449 on the reference name (`uv run python tools/perf/call_count.py`), and the module-`parse` totals in decisions/S6's WHERE THE FACT LIVES entry hold exactly — `John Smith` 176, `Smith, John` 207, `John Smith, Jr.` 233, `Smith, John, Jr.` 257, `Smith, MA` 225. WITH SWITCH B ON the two figures recorded there each drop by one: `Smith, John` 213 → 212 and `Smith, XYZ` 245 → 244, `Parser().parse` on both sides as before. The cause is the dead caps branch leaving `ambiguous_class_candidate`: `segment`'s single-token call used to reach a `caps_shape_candidate` call that could only ever answer False, and now does not. Recompute with that entry's recipe. - 2026-09-30 (Derek), #563 — PAIRED INITIALS ARE THE EXCEPTION TO C1'S NAME-WORD COUNT, AND A FLIP ON THE DOTTED SHAPE ALONE IS SILENT. The 2026-09-14 (Derek) entry above, #516's DOTTED HALF IS A SWITCH, read `John Smith, A.B.` as suffix 'A.B.' on the count of two name words before the comma. The count cannot tell a given name and a family name from ONE surname written in two words, and the dotted shape's own most common member is a person's initials, so the unreleased tree read `García Márquez, G.J.` as given 'García', family 'Márquez', suffix 'G.J.', `De La Cruz, M.J.` with no given name at all, and `van der Berg, A.J.` as given 'van' — where 1.4.0 through 2.3.0 read each as initials. A particle check would have caught only the last two; `García Márquez` and `Lloyd Webber` look exactly like `John Smith`. What the parser CAN see is the token, so the line is drawn there: two single letters joined by a period, with or without one after the second (`_vocab.is_paired_initials`; `García Márquez, G.J` reads as `G.J.` does), read as the given name after the comma and report the fork, and only an UNAMBIGUOUS suffix word IN FRONT of them that is not also title vocabulary, or a second word the class admits by dotted shape in the same part, makes them the credential run (`John Smith, PhD X.Y.`, `John Smith, X.Y. P.Q.`). Three letters or more (`X.Y.Z.`) and any longer chunk (`B.Tech.`) read by the count as before. Three choices made with it (Derek, 2026-09-30): (i) FRONT ONLY — a word behind the pair does not speak, so `De La Cruz, M.J. PhD` keeps given 'M.J.' and `John Smith, A.B. PhD` reads given 'A.B.', the direction rules/S2's company clause already takes; a LISTED dotted word behind does not speak either (`John Smith, A.B. Ph.D.` → given 'A.B.'), the second-word exception being for words the class admits by shape; (ii) SILENT FLIP — a flip resting only on words the class admits by dotted shape reports nothing, `B.Tech.` included, because the only such word a reader takes for a name is a pair of initials, and the comma flips a pair only where a word beside it has already said otherwise; a LISTED member in the same part still reports the flip (`John Smith, PhD MEng`, `John Smith, X.Y.Z. MA`); (iii) THE COMMA SLOT ONLY — the trailing slot (`John Smith R.T.`), the given part's trailing slot after a family comma (`Doe, John R.T.`) and the maiden clause keep #516's reading and its report; #563 leaves the no-comma question open. A generational word in front speaks like any unambiguous suffix word (`García Márquez, Jr. G.J.` → suffix 'Jr. G.J.'), as S2's company clause has it — unless it is also a title, so `García Márquez, Sr G.J.` reads title 'Sr', given 'G.J.'. ACCEPTED: `García Márquez, G.J.R.` reads suffix 'G.J.R.' — initials are conventionally written apart, as separate words this rule never reads (`García Márquez, G. J. R.` gives given 'G.', middle 'J. R.'), and three letters run together are far likelier a credential. REVERSED BY THIS ENTRY rather than edited, for the dotted half at the first slot after a comma only: the #516 SWITCH entry's `John Smith, A.B.` → suffix and its "reports either way"; the 2026-09-14 NO NEW AmbiguityKind entry's "Every decision at these slots emits" SUFFIX_OR_NAME; and rules/C1's "A decision either way at this comma is reported" (rules/C11's since #728). ALSO FIXED: the trailing slot's report described a by-shape pick in the listed member's words ("written without periods is both a post-nominal"), false of `X.Y.Z.` on both counts; it now says the word is shaped like a post-nominal but listed in no vocabulary. - REVIEW ROUND, same day. The docs review found the first version let a word of both the title and the suffix vocabulary speak for the pair, since C1 counts such a word opening the part as a suffix word: `García Márquez, Ms G.J.` read given 'García', family 'Márquez', suffix 'Ms G.J.' with NO report — #563's own defect, made silent — where 2.3.0 read title 'Ms', given 'G.J.'. The twelve title/suffix duals (`ms`, `md`, `sr`, `lt`, `sa`, `ra`, `vc`, `cpl`, `cpo`, `cpt`, `csm`, `sgm`) all reached it. Fixed by letting only a suffix word that is not title vocabulary speak: in front of a pair, a dual is the title of the given part the pair opens, as S2 already reads a dual in that part's title run, and `Ms G.J.`, `MD G.J.`, `Lt G.J.` now read title plus given as every release did. The title lookup runs only once a pair is met. The code review's frame count found the single-token path asking `ambiguous_class_member` a second time, +2 frames on every reporting comma name (`John Smith, MA`, 252 → 254, profiler call events); for a word past the candidate test LISTED is exactly "no period" (`ambiguous_class_member` declines any period), which is now asked inline. Measured after both fixes against master, same counter: `Smith, John` 183 and `John Smith, MA` 252 on both trees, `John Smith, X.Y.Z.` 295 → 286 (the report it no longer builds), and `García Márquez, G.J.` 338 → 350, the family-comma reading and its report costing more than the flip did. - SECOND REVIEW ROUND, same day, on the first round's fix commit. (a) TWO PAIRS (Derek): `De La Cruz, M.J. K.L.` read family 'De La Cruz', suffix 'M.J. K.L.' — no given name — in silence, the silent-flip rationale ("only paired initials are taken for a name") failing on exactly the case where each word speaking for a pair is itself a pair. The reading stands, two dotted groups not being how anyone writes a person's initials, but that flip now REPORTS: when the only words speaking for a pair are other pairs, every shape word in the part being one. `John Smith, X.Y. P.Q.` gains the report with it; `John Smith, X.Y.Z. G.J.` and `John Smith, PhD G.J. K.L.`, where something else speaks, stay silent. The same rule reports `García Márquez, Ms G.J. K.L.`, whose honorific the run takes, where the first round's fix took it in silence. (b) A CLASS MEMBER IN FRONT SPEAKS FOR NOTHING (Derek): `García Márquez, Ed G.J.`, `Ma G.J.` and `MA G.J.` flipped on the member in front, where S2's company lets only an unambiguous credential speak for a member. Now the comma keeps the family, given 'Ed' — and 'G.J.' then ends the given part, the slot (iii) left alone, so it reads suffix 'G.J.' as `Doe, John R.T.` does, both forks reported; 1.4.0 and 2.3.0 gave middle 'G.J.'. Resolving that slot is the no-comma question #563 leaves open. (c) WORDING, no behavior: C1's sentence counting a dual opening a part as a suffix word now defers to the pair's titles; the pair's report reaches it only where it opens the part, `Smith, Ms G.J.` having always been silent (the comma report's reach is the first post-comma piece, 2026-09-18); and the silence is stated as "no listed word takes part", which covers `García Márquez, PhD G.J.`, where "dotted-shape words alone" did not. + REVIEW ROUND, same day. The docs review found the first version let a word of both the title and the suffix vocabulary speak for the pair, since C1 (rules/C4 since #728) counts such a word opening the part as a suffix word: `García Márquez, Ms G.J.` read given 'García', family 'Márquez', suffix 'Ms G.J.' with NO report — #563's own defect, made silent — where 2.3.0 read title 'Ms', given 'G.J.'. The twelve title/suffix duals (`ms`, `md`, `sr`, `lt`, `sa`, `ra`, `vc`, `cpl`, `cpo`, `cpt`, `csm`, `sgm`) all reached it. Fixed by letting only a suffix word that is not title vocabulary speak: in front of a pair, a dual is the title of the given part the pair opens, as S2 already reads a dual in that part's title run, and `Ms G.J.`, `MD G.J.`, `Lt G.J.` now read title plus given as every release did. The title lookup runs only once a pair is met. The code review's frame count found the single-token path asking `ambiguous_class_member` a second time, +2 frames on every reporting comma name (`John Smith, MA`, 252 → 254, profiler call events); for a word past the candidate test LISTED is exactly "no period" (`ambiguous_class_member` declines any period), which is now asked inline. Measured after both fixes against master, same counter: `Smith, John` 183 and `John Smith, MA` 252 on both trees, `John Smith, X.Y.Z.` 295 → 286 (the report it no longer builds), and `García Márquez, G.J.` 338 → 350, the family-comma reading and its report costing more than the flip did. + SECOND REVIEW ROUND, same day, on the first round's fix commit. (a) TWO PAIRS (Derek): `De La Cruz, M.J. K.L.` read family 'De La Cruz', suffix 'M.J. K.L.' — no given name — in silence, the silent-flip rationale ("only paired initials are taken for a name") failing on exactly the case where each word speaking for a pair is itself a pair. The reading stands, two dotted groups not being how anyone writes a person's initials, but that flip now REPORTS: when the only words speaking for a pair are other pairs, every shape word in the part being one. `John Smith, X.Y. P.Q.` gains the report with it; `John Smith, X.Y.Z. G.J.` and `John Smith, PhD G.J. K.L.`, where something else speaks, stay silent. The same rule reports `García Márquez, Ms G.J. K.L.`, whose honorific the run takes, where the first round's fix took it in silence. (b) A CLASS MEMBER IN FRONT SPEAKS FOR NOTHING (Derek): `García Márquez, Ed G.J.`, `Ma G.J.` and `MA G.J.` flipped on the member in front, where S2's company lets only an unambiguous credential speak for a member. Now the comma keeps the family, given 'Ed' — and 'G.J.' then ends the given part, the slot (iii) left alone, so it reads suffix 'G.J.' as `Doe, John R.T.` does, both forks reported; 1.4.0 and 2.3.0 gave middle 'G.J.'. Resolving that slot is the no-comma question #563 leaves open. (c) WORDING, no behavior: C1's (now rules/C4's) sentence counting a dual opening a part as a suffix word now defers to the pair's titles; the pair's report reaches it only where it opens the part, `Smith, Ms G.J.` having always been silent (the comma report's reach is the first post-comma piece, 2026-09-18); and the silence is stated as "no listed word takes part", which covers `García Márquez, PhD G.J.`, where "dotted-shape words alone" did not. SIMPLIFY ROUND, same day, behavior-identical (0 diffs over 21,604 parses: every corpus name, every quoted string in tests/v2/cases.py and 115 composed `pre, post` probes, under four policies, comparing fields and ambiguity details against the pre-round commit c125f69b; the same harness finds 252 diffs against master). One finding was a cost, not a style point: the speaker test scanned every word in front of EACH pair for a non-title, so `John Smith, MD MD ... G.J. G.J. ...` cost duals × pairs `_normalize` calls (163 at 8 of each, 1,387 at 32, py3.11). Only the first pair's scan can change the answer, since every later pair has the same words in front and more, so it is asked once: 107 and 395. `tests/v2/test_benchmark.py::test_the_paired_initials_title_scan_does_not_cost_quadratically` guards the ratio and fails at c125f69b. The run loop also asks LISTED as "no period", as the single-token test does, and `flip_reports` is set once after the run decision rather than piecemeal. MEASURED 2026-09-30 against master b98b26e3, every name in this branch's `tools/differential/corpus*.jsonl` parsed on both trees with `nameparser.__file__` asserted on each side: 11 of 1453 distinct names differ, every one of them a name this change's rules.md examples and case rows put in the corpus (the two-pair names `De La Cruz, M.J. K.L.` and `John Smith, X.Y. P.Q.` are not among them: after the second round they read and report exactly as master does). THE POPULATION THAT COULD MOVE is the shape's, and the corpus barely holds it: over master's 1441 distinct names, 13 have a pair opening the part after the first comma, and `John Smith, A.B.` is the only one behind two or more NAME words with an unlisted, non-CJK pair (`Smith Jr., A.B.` has one name word, `Kenneth Clarke Q.C., M.P.` and `Virginia G. Essandoh, J.D.` hold listed acronyms, the rest one word) — so it is the only mover over that corpus, and the count is evidence about the corpus rather than about the rule's reach. Recompute: check out the parent into a separate worktree, parse every corpus name in each tree under `PYTHONSAFEPATH=1` with the tree's root first on `sys.path`, and diff `as_dict()` plus the sorted ambiguity kinds; for the population, take each name whose text after its first comma opens with a token matching `[^\W\d_]\.[^\W\d_]\.?` whole. - 2026-10-01 (Derek), #564 — THE ALL-CAPS HALF READS THE COMMA POSITION BY DEFAULT, AND THE SWITCH HAS THREE SETTINGS. Supersedes the default of the 2026-09-14 entry above (its reasoning stands for the positions it was argued over). That entry turned the whole caps half off because French and Korean records write the SURNAME in capitals; but the convention writes them at the end of a name (`Jean DUPONT`) or before a comma (`DUPONT, Jean`), never after a comma behind a full name, so the reason for the off default never reached the comma position and that position was switched off with it. The corpus held three names of exactly that shape — `Ahmad Jayadi, CHA`, `John Smith, RAI`, `The Rt Hon Kenneth Clarke QC MP, HMG` — all credentials, all read as the given name at 2.3.0. - DECIDED, with Derek's calls marked. (1) `Policy.unlisted_caps_suffixes` becomes a `CapsSuffixes` StrEnum, OFF / AFTER_COMMA / EVERYWHERE, default AFTER_COMMA (Derek: an enum over two booleans or a mixed `bool | "comma"`, so every state is named and no meaningless combination exists). The field was new in 2.4 and unreleased, so no deprecation is owed; a bool raises a TypeError naming both replacements. The third value exists because redefining "off" would have left no setting that keeps a given name written in capitals (Derek's question, `García Márquez, GABRIEL`). (2) AFTER_COMMA reads only the part right after the first comma, behind two or more name words — the comma's own structure decision, which segment makes from the text before classify runs. The given part's last word after a family comma (`Doe, John XYZ`) and a maiden clause's last word are trailing slots and stay behind EVERYWHERE, as the comma-less end of a name does. (3) A lone two-letter word is never admitted at the comma, in any setting (Derek): it is the undotted form of #563's paired initials, and two words before the comma may be one surname, so `García Márquez, MJ` keeps given 'MJ'. Inside a longer part a two-letter word is read exactly as #563 reads the dotted pair — measured 2026-10-01 on the shipped tree by swapping `MJ` ↔ `M.J.` in runs of one to three words from {MJ, PhD, XYZ, CPA, Jr., MA, LEED, III} after seven name prefixes whose own words carry the contrast (`García Márquez`, `Smith Jones`, `Mr García Márquez`, `de García Márquez`, `García Márquez née Smith`, `John st Smith`, `García y López`): the comma's structure decision differed on 0 of 1295. After five all-caps prefixes it differed on 325 of 925, by design, `MJ` being no class member without the contrast while the dotted shape is case-free (`GARCÍA MÁRQUEZ, CPA MJ` keeps given 'CPA' where `CPA M.J.` makes the run). Recompute: run segment and read `ParseState.structure` for each pair. The equivalence is the comma's decision only: at the given part's last word after a family comma a dotted `M.J.` is a credential by shape (S3) where `MJ` is one only under EVERYWHERE, so `García Márquez, MA M.J.` reads suffix 'M.J.' and `MA MJ` middle 'MJ' — so `García Márquez, MJ PhD` keeps given 'MJ' as `De La Cruz, M.J. PhD` does, and C1 says so by pointing at #563's own sentences rather than restating them. Two restatements were wrong: the first draft's "three letters or more, or a run holding such a word" (both reviews), and its replacement, "with only a credential behind it ... while a credential in front makes the run", which #563's vocabulary rules contradict (`John Smith, MA MJ` reads given 'MA'; `García Márquez, MJ XYZ` makes the run). (3b) An unlisted all-caps word is a by-shape member of C1's run (#544), beside listed credentials, not only in a run of all-caps words (Derek, after review): before, `John Smith, XYZ` read suffix while `John Smith, PhD XYZ` read given 'PhD' and `John Smith, XYZ Jr.` given 'XYZ' — more evidence for a credential producing a name reading, already true under the old `True` and made a default by this change. The contrast has to be the NAME's, and it is carried only by one of the name's own words before the comma (`own_words`, so no maiden clause or delimited content) holding a capital and whose last LETTER is lowercase (`_vocab.written_as_a_name`), with no period and not claimed by a wordlist as a title, particle, connective, credential, generation, maiden marker or honorific (`_vocab.claimed_as_non_name`) (Derek). Every earlier draft asked for "a lowercase letter somewhere, less some exclusions", and each leaked a class the next review found: a mixed-case credential (`LLOYD WEBBER, ANDREW PhD`), a maiden clause or title (`LLOYD WEBBER née Smith, ANDREW PhD`, `Mr LLOYD WEBBER, ANDREW`), a generation, connective or shape title (`LLOYD WEBBER Jr., ANDREW`, `GARCÍA y LÓPEZ, ANDREW`, `Insp. LLOYD WEBBER, ANDREW`), and glued particles and unlisted lowercase words (`GISCARD d'ESTAING, VALÉRY`, `HAFEZ al-ASSAD, BASHAR`, `LLOYD ap RHYS, DAFYDD`) — no closed list separates an all-caps record from a mixed-case name. A Title-case draft (`str.istitle()`) closed those but read too narrowly, missing interior capitals and elisions (`DiCaprio`, `IJzerman`, `al-Rashid`, `d'Estaing`, `McDonald` — every one a mixed-case name that kept a given 'XYZ') and taking a lone capital as Title case (`de GAULLE C, CHARLES` lost its given name). Derek weighed "contains a capital" (an all-caps record with any lowercase word then always carries the contrast) and "not all capitals but contains a capital" (which takes `d'ESTAING` and `al-ASSAD`, the lowercase standing before the capitals) and first chose a capital directly followed by a lowercase letter, with a leading `Mc`/`Mac` skipped. Its review found the skip was one member of a class: any capitalized prefix glued to a surname written in capitals supplies such a pair (`LLOYD FitzGERALD, RONALD`, `PAOLO DeVITO, MARCO`, `JEAN LaFLEUR, PIERRE`, `DICK VanDYKE, JOHN`, `PAUL DuBOIS, JEAN`, `JEAN St-PIERRE, MARC`, and `LLOYD SMITH-McDONALD, RONALD`, the skip applying only at a word's start), as does `ß`, which has no capital (`MÜLLER WEIß, HANS`) — each lost its given name — while titlecase digraphs failed it (`Džokić Ljubić, XYZ`) and it cost a frame per letter. The shipped test, Derek's: a capital in the word and its last letter lowercase. A surname written in capitals ends in a capital whatever is glued in front of it, so every one of those records keeps its given name with no prefix list, and `McDonald` and `Džokić` pass. "Last letter" is the last letter that carries CASE, not the last character: the word is composed (NFC) first; every non-letter and every caseless letter is passed over (`Jones'`, `Smith2`, `Smith)`, `Jonesʼ` with U+02BC, the transliteration letters in `Wafāʾ`, a trailing `李`); and so is a lowercase letter whose capital is not one character (`ß` → `SS`, `ĸ` with none, the `fi` ligature), which is no evidence of case — `WEIß` is written in capitals, `Weiß` is not. Two cuts got this wrong: the first tested the last character, and its review found every name typed with decomposed accents and ending in an accented letter losing the contrast (`José André, XYZ`, `Lê Thị Hà, XYZ`, `René Noé, XYZ`, `Chloé Zoé, MBA XYZ` read given where their composed spellings read suffix), with `Jones'`, `Smith2` and `Smith)`; the second skipped non-letters but stopped at a caseless letter, so `Asmāʾ Wafāʾ, XYZ` and `JOHN Jonesʼ, XYZ` kept given 'XYZ' (its review). MEASURED 2026-10-02 against `a16927c6`, the capital-then-lowercase commit: the seven capitalized-surname prefixes (`FitzGERALD`, `DeVITO`, `LaFLEUR`, `VanDYKE`, `DuBOIS`, `St-PIERRE`, `SMITH-McDONALD`), `MÜLLER WEIß` and `KUJAĸ de PETERSEN` keep their given names, `Džokić Ljubić` gains the contrast, `JOHN O'NEILL's` and `JEAN-pierre DUPONT` gain one (accepted below), and nothing else moves; the decomposed-accent names, `Asmāʾ Wafāʾ` and `JOHN Jonesʼ` read as `a16927c6` reads them. OFF on this tree equals `unlisted_caps_suffixes=False` on `7394078a` (origin/master at the time) on all 4286 inputs. RECIPE, the comparator being those two commits (`git archive` each): the inputs are every name in `tools/differential/corpus*.jsonl`, every match of the regex `"([^"\n]{2,60})"` in `tests/v2/cases.py` holding a comma (a text match, not a literal parser, so fragments of case notes are included, harmlessly; extracting the literals exactly with `tokenize` gave 4273 inputs and the same conclusions), and each of these prefixes joined by ", " to each of the parts `XYZ`, `ANDREW`, `ANDREW PhD`, `PhD XYZ`, `MJ`, `XYZ Jr.` — `John Smith`, `DiCaprio LaBeouf`, `IJzerman IJsselmeer`, `al-Rashid al-Hassan`, `d'Estaing d'Orléans`, `McDonald MacLeod`, `Džokić Ljubić`, `Asmāʾ Wafāʾ`, `John Smith-Jones`, `john smith`, `García y López`, `LLOYD WEBBER`, `GISCARD d'ESTAING`, `HAFEZ al-ASSAD`, `LLOYD McDONALD`, `LLOYD FitzGERALD`, `PAOLO DeVITO`, `JEAN LaFLEUR`, `DICK VanDYKE`, `PAUL DuBOIS`, `JEAN St-PIERRE`, `LLOYD SMITH-McDONALD`, `MÜLLER WEIß`, `KUJAĸ de PETERSEN`, `LLOYD ap RHYS`, `de GAULLE C`, `Mr LLOYD WEBBER`, `LLOYD WEBBER née Smith`, `JOHN O'NEILL's`, `JOHN SMITHs`, `JEAN-pierre DUPONT`, `JOHN Jones'`, `JOHN Jonesʼ`, `JOHN Smith)`, `JOHN Smith2`, and the NFD forms of `José André`, `Lê Thị Hà`, `René Noé`, `Chloé Zoé`. Parse each under OFF, AFTER_COMMA and EVERYWHERE and compare `as_dict()`, the ambiguity kinds and `capitalized(force=True)`; the population is a fact of the tree it runs on (4286 here), since the corpora and the case table grow. ACCEPTED with it (Derek): a name written wholly in lowercase reads the listing form, `john smith, XYZ` keeping given 'XYZ', an unlisted title written in mixed case (`Doña GARCÍA LÓPEZ, MARÍA`) still carries the contrast, and so do two writings in an otherwise all-caps record that end in a lowercase letter: a glued possessive or plural (`JOHN O'NEILL's, XYZ`, `JOHN SMITHs, XYZ` read suffix `XYZ`) and a hyphenated part written in mixed case or lowercase (`JOHN SMITH-Jones, XYZ`, `JEAN-pierre DUPONT, XYZ`). ACCEPTED with (4): in a mixed-case name a capitalized given name followed by a generation goes the same way as one alone, `García Márquez, JUAN Jr.` reading suffix 'JUAN Jr.', reported; and behind a particle-led surname of two name words the flip leaves no given name at all, `De La Cruz García, MARÍA` reading family 'De La Cruz García', suffix 'MARÍA', because P1 reads a never-given particle's part as all surname. (4) ACCEPTED (Derek): a given name written in capitals behind a two-word surname without particles reads as the credential, `García Márquez, GABRIEL` giving given 'García', family 'Márquez', suffix 'GABRIEL', reported; capitalizing the given and not the family is not a convention, and OFF is the escape hatch. #575 was the prerequisite: before it, the count read `Van Buren, MARTIN` and `De La Cruz, MARIA` as two or three name words and this default would have taken their given names too. - WHAT MOVES, measured 2026-10-01 with the gate at all five baselines. The three corpus names above, plus the C1 examples `John Smith, XYZ` and `John Smith, LEED AP` — the latter closing a `deviates: #291` marker rules.md had carried under a closed issue — classified by `fix(#564)` in every ledger; the radar-unclassified count is what it was before the change at every baseline. `John Smith, RAI` and `Ahmad Jayadi, CHA` read suffix again by their capitals, as they did by vocabulary before #342 removed both words: parity at 1.4.0, only the comma's report at 2.0 through 2.2, so the #342 rule's `fields` lose `given` (the OVER-DECLARED check) and the watched shape for `John Smith, RAI` is re-recorded at those four baselines. `Smith, XYZ` keeps given 'XYZ' and, at the default, reports nothing; EVERYWHERE still reports the declined fork there, as it did. + DECIDED, with Derek's calls marked. (1) `Policy.unlisted_caps_suffixes` becomes a `CapsSuffixes` StrEnum, OFF / AFTER_COMMA / EVERYWHERE, default AFTER_COMMA (Derek: an enum over two booleans or a mixed `bool | "comma"`, so every state is named and no meaningless combination exists). The field was new in 2.4 and unreleased, so no deprecation is owed; a bool raises a TypeError naming both replacements. The third value exists because redefining "off" would have left no setting that keeps a given name written in capitals (Derek's question, `García Márquez, GABRIEL`). (2) AFTER_COMMA reads only the part right after the first comma, behind two or more name words — the comma's own structure decision, which segment makes from the text before classify runs. The given part's last word after a family comma (`Doe, John XYZ`) and a maiden clause's last word are trailing slots and stay behind EVERYWHERE, as the comma-less end of a name does. (3) A lone two-letter word is never admitted at the comma, in any setting (Derek): it is the undotted form of #563's paired initials, and two words before the comma may be one surname, so `García Márquez, MJ` keeps given 'MJ'. Inside a longer part a two-letter word is read exactly as #563 reads the dotted pair — measured 2026-10-01 on the shipped tree by swapping `MJ` ↔ `M.J.` in runs of one to three words from {MJ, PhD, XYZ, CPA, Jr., MA, LEED, III} after seven name prefixes whose own words carry the contrast (`García Márquez`, `Smith Jones`, `Mr García Márquez`, `de García Márquez`, `García Márquez née Smith`, `John st Smith`, `García y López`): the comma's structure decision differed on 0 of 1295. After five all-caps prefixes it differed on 325 of 925, by design, `MJ` being no class member without the contrast while the dotted shape is case-free (`GARCÍA MÁRQUEZ, CPA MJ` keeps given 'CPA' where `CPA M.J.` makes the run). Recompute: run segment and read `ParseState.structure` for each pair. The equivalence is the comma's decision only: at the given part's last word after a family comma a dotted `M.J.` is a credential by shape (S3) where `MJ` is one only under EVERYWHERE, so `García Márquez, MA M.J.` reads suffix 'M.J.' and `MA MJ` middle 'MJ' — so `García Márquez, MJ PhD` keeps given 'MJ' as `De La Cruz, M.J. PhD` does, and C1 (rules/C5 since #728) says so by pointing at #563's own sentences rather than restating them. Two restatements were wrong: the first draft's "three letters or more, or a run holding such a word" (both reviews), and its replacement, "with only a credential behind it ... while a credential in front makes the run", which #563's vocabulary rules contradict (`John Smith, MA MJ` reads given 'MA'; `García Márquez, MJ XYZ` makes the run). (3b) An unlisted all-caps word is a by-shape member of C1's run (rules/C4's and C6's since #728) (#544), beside listed credentials, not only in a run of all-caps words (Derek, after review): before, `John Smith, XYZ` read suffix while `John Smith, PhD XYZ` read given 'PhD' and `John Smith, XYZ Jr.` given 'XYZ' — more evidence for a credential producing a name reading, already true under the old `True` and made a default by this change. The contrast has to be the NAME's, and it is carried only by one of the name's own words before the comma (`own_words`, so no maiden clause or delimited content) holding a capital and whose last LETTER is lowercase (`_vocab.written_as_a_name`), with no period and not claimed by a wordlist as a title, particle, connective, credential, generation, maiden marker or honorific (`_vocab.claimed_as_non_name`) (Derek). Every earlier draft asked for "a lowercase letter somewhere, less some exclusions", and each leaked a class the next review found: a mixed-case credential (`LLOYD WEBBER, ANDREW PhD`), a maiden clause or title (`LLOYD WEBBER née Smith, ANDREW PhD`, `Mr LLOYD WEBBER, ANDREW`), a generation, connective or shape title (`LLOYD WEBBER Jr., ANDREW`, `GARCÍA y LÓPEZ, ANDREW`, `Insp. LLOYD WEBBER, ANDREW`), and glued particles and unlisted lowercase words (`GISCARD d'ESTAING, VALÉRY`, `HAFEZ al-ASSAD, BASHAR`, `LLOYD ap RHYS, DAFYDD`) — no closed list separates an all-caps record from a mixed-case name. A Title-case draft (`str.istitle()`) closed those but read too narrowly, missing interior capitals and elisions (`DiCaprio`, `IJzerman`, `al-Rashid`, `d'Estaing`, `McDonald` — every one a mixed-case name that kept a given 'XYZ') and taking a lone capital as Title case (`de GAULLE C, CHARLES` lost its given name). Derek weighed "contains a capital" (an all-caps record with any lowercase word then always carries the contrast) and "not all capitals but contains a capital" (which takes `d'ESTAING` and `al-ASSAD`, the lowercase standing before the capitals) and first chose a capital directly followed by a lowercase letter, with a leading `Mc`/`Mac` skipped. Its review found the skip was one member of a class: any capitalized prefix glued to a surname written in capitals supplies such a pair (`LLOYD FitzGERALD, RONALD`, `PAOLO DeVITO, MARCO`, `JEAN LaFLEUR, PIERRE`, `DICK VanDYKE, JOHN`, `PAUL DuBOIS, JEAN`, `JEAN St-PIERRE, MARC`, and `LLOYD SMITH-McDONALD, RONALD`, the skip applying only at a word's start), as does `ß`, which has no capital (`MÜLLER WEIß, HANS`) — each lost its given name — while titlecase digraphs failed it (`Džokić Ljubić, XYZ`) and it cost a frame per letter. The shipped test, Derek's: a capital in the word and its last letter lowercase. A surname written in capitals ends in a capital whatever is glued in front of it, so every one of those records keeps its given name with no prefix list, and `McDonald` and `Džokić` pass. "Last letter" is the last letter that carries CASE, not the last character: the word is composed (NFC) first; every non-letter and every caseless letter is passed over (`Jones'`, `Smith2`, `Smith)`, `Jonesʼ` with U+02BC, the transliteration letters in `Wafāʾ`, a trailing `李`); and so is a lowercase letter whose capital is not one character (`ß` → `SS`, `ĸ` with none, the `fi` ligature), which is no evidence of case — `WEIß` is written in capitals, `Weiß` is not. Two cuts got this wrong: the first tested the last character, and its review found every name typed with decomposed accents and ending in an accented letter losing the contrast (`José André, XYZ`, `Lê Thị Hà, XYZ`, `René Noé, XYZ`, `Chloé Zoé, MBA XYZ` read given where their composed spellings read suffix), with `Jones'`, `Smith2` and `Smith)`; the second skipped non-letters but stopped at a caseless letter, so `Asmāʾ Wafāʾ, XYZ` and `JOHN Jonesʼ, XYZ` kept given 'XYZ' (its review). MEASURED 2026-10-02 against `a16927c6`, the capital-then-lowercase commit: the seven capitalized-surname prefixes (`FitzGERALD`, `DeVITO`, `LaFLEUR`, `VanDYKE`, `DuBOIS`, `St-PIERRE`, `SMITH-McDONALD`), `MÜLLER WEIß` and `KUJAĸ de PETERSEN` keep their given names, `Džokić Ljubić` gains the contrast, `JOHN O'NEILL's` and `JEAN-pierre DUPONT` gain one (accepted below), and nothing else moves; the decomposed-accent names, `Asmāʾ Wafāʾ` and `JOHN Jonesʼ` read as `a16927c6` reads them. OFF on this tree equals `unlisted_caps_suffixes=False` on `7394078a` (origin/master at the time) on all 4286 inputs. RECIPE, the comparator being those two commits (`git archive` each): the inputs are every name in `tools/differential/corpus*.jsonl`, every match of the regex `"([^"\n]{2,60})"` in `tests/v2/cases.py` holding a comma (a text match, not a literal parser, so fragments of case notes are included, harmlessly; extracting the literals exactly with `tokenize` gave 4273 inputs and the same conclusions), and each of these prefixes joined by ", " to each of the parts `XYZ`, `ANDREW`, `ANDREW PhD`, `PhD XYZ`, `MJ`, `XYZ Jr.` — `John Smith`, `DiCaprio LaBeouf`, `IJzerman IJsselmeer`, `al-Rashid al-Hassan`, `d'Estaing d'Orléans`, `McDonald MacLeod`, `Džokić Ljubić`, `Asmāʾ Wafāʾ`, `John Smith-Jones`, `john smith`, `García y López`, `LLOYD WEBBER`, `GISCARD d'ESTAING`, `HAFEZ al-ASSAD`, `LLOYD McDONALD`, `LLOYD FitzGERALD`, `PAOLO DeVITO`, `JEAN LaFLEUR`, `DICK VanDYKE`, `PAUL DuBOIS`, `JEAN St-PIERRE`, `LLOYD SMITH-McDONALD`, `MÜLLER WEIß`, `KUJAĸ de PETERSEN`, `LLOYD ap RHYS`, `de GAULLE C`, `Mr LLOYD WEBBER`, `LLOYD WEBBER née Smith`, `JOHN O'NEILL's`, `JOHN SMITHs`, `JEAN-pierre DUPONT`, `JOHN Jones'`, `JOHN Jonesʼ`, `JOHN Smith)`, `JOHN Smith2`, and the NFD forms of `José André`, `Lê Thị Hà`, `René Noé`, `Chloé Zoé`. Parse each under OFF, AFTER_COMMA and EVERYWHERE and compare `as_dict()`, the ambiguity kinds and `capitalized(force=True)`; the population is a fact of the tree it runs on (4286 here), since the corpora and the case table grow. ACCEPTED with it (Derek): a name written wholly in lowercase reads the listing form, `john smith, XYZ` keeping given 'XYZ', an unlisted title written in mixed case (`Doña GARCÍA LÓPEZ, MARÍA`) still carries the contrast, and so do two writings in an otherwise all-caps record that end in a lowercase letter: a glued possessive or plural (`JOHN O'NEILL's, XYZ`, `JOHN SMITHs, XYZ` read suffix `XYZ`) and a hyphenated part written in mixed case or lowercase (`JOHN SMITH-Jones, XYZ`, `JEAN-pierre DUPONT, XYZ`). ACCEPTED with (4): in a mixed-case name a capitalized given name followed by a generation goes the same way as one alone, `García Márquez, JUAN Jr.` reading suffix 'JUAN Jr.', reported; and behind a particle-led surname of two name words the flip leaves no given name at all, `De La Cruz García, MARÍA` reading family 'De La Cruz García', suffix 'MARÍA', because P1 reads a never-given particle's part as all surname. (4) ACCEPTED (Derek): a given name written in capitals behind a two-word surname without particles reads as the credential, `García Márquez, GABRIEL` giving given 'García', family 'Márquez', suffix 'GABRIEL', reported; capitalizing the given and not the family is not a convention, and OFF is the escape hatch. #575 was the prerequisite: before it, the count read `Van Buren, MARTIN` and `De La Cruz, MARIA` as two or three name words and this default would have taken their given names too. + WHAT MOVES, measured 2026-10-01 with the gate at all five baselines. The three corpus names above, plus the C1 examples (rules/C6's since #728) `John Smith, XYZ` and `John Smith, LEED AP` — the latter closing a `deviates: #291` marker rules.md had carried under a closed issue — classified by `fix(#564)` in every ledger; the radar-unclassified count is what it was before the change at every baseline. `John Smith, RAI` and `Ahmad Jayadi, CHA` read suffix again by their capitals, as they did by vocabulary before #342 removed both words: parity at 1.4.0, only the comma's report at 2.0 through 2.2, so the #342 rule's `fields` lose `given` (the OVER-DECLARED check) and the watched shape for `John Smith, RAI` is re-recorded at those four baselines. `Smith, XYZ` keeps given 'XYZ' and, at the default, reports nothing; EVERYWHERE still reports the declined fork there, as it did. CASE REPAIR FOLLOWS THE READING. The comma decision is segment's, made from the text, while case repair keeps a word in capitals only where classify wrote the shape tag (rules/R4); the first draft tagged only under EVERYWHERE, so `parse("John Smith, XYZ").capitalized(force=True)` rendered 'John Smith Xyz' at the default and 'John Smith XYZ' under EVERYWHERE (found by the docs review, axis 5). Classify now tags a caps-shaped word in the part a suffix comma opened under AFTER_COMMA too, so both settings render that part alike, and rules/R4 names the caps shape beside the dotted one; `test_render`'s forced-repair test fails with the tagging removed (an R4 example could not witness it, R5 leaving a mixed-case name unrepaired unless forced). A caps word in a third or later comma part is still tagged only under EVERYWHERE, so `John Smith, MD, XYZ` renders 'Xyz' at the default, as master does. COST, `sys.setprofile` call counts (mean of 50, after one warm-up) against `git archive origin/master`: `Smith, John` 183 → 183, `Smith, JOHN` 183 → 183, `Smith, XYZ` 182 → 182, `John Smith, PhD` 217 → 217, `John Smith, MA` 252 → 252, `John Smith, Ph. D.` 254 → 254, `John Smith, CPA` 217 → 217, `John Smith, MD PhD` 260 → 260, `John Smith, MBA CPA` 261 → 261, `Dr. Juan de la Vega III` 366 → 366, `John Smith, XYZ` 251 → 266, and an all-caps record with a capitalized given name and a credential, `LLOYD WEBBER, ANDREW PhD`, 338 → 364, a constant +26 that does not grow with the name's length (`'LLOYD'*64 + ' WEBBER, ANDREW PhD'` 653 → 679; the second draft's per-character contrast test had made that +350). Past the all-caps check the contrast is linear in the words before the comma, about four frames a word (the generator, the case test's call, and the own-words walk's fold and marker test; `'GAULLE ' + 'ap '*k + 'GAULLE, CHARLES'` is +7, +26, +38, +54 over master at k = 0, 1, 4, 8), and constant in a word's letters (`'de ' + 'GAULLE'*k + ', CHARLES'` 253 → 275, 343 → 365 and 631 → 653 at k = 1, 16, 64), where the capital-then-lowercase draft grew a frame per letter; all paid only once a caps word is in hand. The comma test is on by default, so three C-level checks go before any call: two or more words before the comma (the first draft lacked it, and `Smith, JOHN`, a common record format, paid +25 for a flip it can never make — the code review), the first word in capitals, and the lone-two-letter length. Every caller of the caps predicate asks first, in C, what it would decline anyway — alphabetic capitals, not a listed suffix word — so a listed credential never pays for the call (the review of the first fix found `John Smith, CPA` +4 and `John Smith, Ph. D.` +5 before these). The 2026-09-14 entry's recompute recipe predates the enum: its `on` is `Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)` now, and its `off=Parser()` is `Policy(unlisted_caps_suffixes=CapsSuffixes.OFF)`, `Parser()` being AFTER_COMMA. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. diff --git a/docs/design/decisions/suffix-acronym-collisions.md b/docs/design/decisions/suffix-acronym-collisions.md index 6c179d0c..196e8323 100644 --- a/docs/design/decisions/suffix-acronym-collisions.md +++ b/docs/design/decisions/suffix-acronym-collisions.md @@ -16,8 +16,8 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See decisions/S9 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; decisions/S3 records the three names that pay for it. -- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules/S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. The comma re-read (with its silent title-dual case) and whether a listed member's chunked dotted spelling should pass the period gate are [#544](https://github.com/derek73/python-nameparser/issues/544)'s questions. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count; bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). +- **2026-09-25 (#540) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules/S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. The comma re-read (with its silent title-dual case) and whether a listed member's chunked dotted spelling should pass the period gate are [#544](https://github.com/derek73/python-nameparser/issues/544)'s questions. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count (rules/C4 since #728); bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). - **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. - **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so the population that could move is this change's own shape-tagged case rows, and every mover is one of them. Classified by `fix(#540)` rules: at each 2.x baseline `wang meng` and `tran lac` move {family, suffix, _ambiguities}, `john smith MEng` and `John Smith MEng PhD` move {middle, family, suffix, _ambiguities}, and `john smith meng` and `nguyen van lac` `_ambiguities` alone; `Smith, John MEng` moves {middle, suffix, _ambiguities} at 2.x and {middle, suffix} at 1.4.0; `Smith, meng` and `Smith, MEng` move {given, suffix, _ambiguities} at 2.x and nothing at 1.4.0 (1.4.0's own reading); `John Smith, PhD MEng` moves {given, middle, family, suffix, _ambiguities} and `john smith, phd meng` {given, family, suffix, _ambiguities} at 2.x (without `_ambiguities` at 1.4.0); `Nguyen Van Lac` moves {family, suffix, _ambiguities} at 2.x and {family, suffix} at 1.4.0; and `Wang M.Eng.` moves {family, suffix, _ambiguities} at 2.x only, 1.4.0 reading the same family for an unrelated reason (its two-piece rule -- a lone word after the given name is the family -- not S2's gate). `nguyen van lac` also diffs at 1.4.0 on `_initials` (`n.` → `n. v.`) for fix(#385/#402)'s reason — its family `van` is a one-particle part — and joins that rule's literal list; the same case-insensitive regex also reaches `Nguyen Van Lac` at every baseline, without explaining anything there (its own family move, at 1.4.0 too, is explained by the Title-case Lac rule instead). Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; this change raised them by seven at 1.4.0 and by thirteen at each 2.x baseline. -- **2026-09-27 (#544) — #540's two pending questions are answered, and the #540 bullet stands as it landed.** The chunked dotted spelling passes S2's period gate — `Wang M.Eng.` reads suffix 'M.Eng.' as `Wang M.A.` does, and as 2.0.0 through 2.3.0 read it — and the comma is kept: a credential run after a full name that ends in a member is the credential run by C1's name-word count (`John Smith, PhD MEng` suffix 'PhD MEng'; `Jane Doe, MS LAc` suffix 'MS LAc' where #540 left title 'MS', given 'LAc', at the cost, accepted in decisions/S7, of `John Smith, Ms Ma` reading suffix 'Ms Ma'). The walk-stopping cost that bullet lists for a run ENDING in a member goes with them, by S2's company clause: `Mary Jones PhD MEng` and `Jane Doe MS LAc` read the whole run as a suffix again. Where the member LEADS the run nothing speaks for it, so `John Smith MEng PhD` keeps its cost. The `fix(#540)` ledger rules for the comma run and the dotted spelling are retired. `fix(#544)` rules classify the comma-run names; `Wang M.Eng.`, whose roles every release from 2.0.0 read, is classified by `feat(#449)`'s given-or-family report rule at 2.0.0 through 2.2.0 and by the dotted `fix(suffix-routing)` rule beside `Jack M.A.` at 1.4.0, and differs from 2.3.0 not at all. The case rows the #540 bullet cites were renamed with the readings they pin: `a_declined_ambiguous_pick_stops_the_walk` is `a_credential_in_front_anchors_a_declined_pick` (`abdul Smith Jr Ma`, suffix 'Jr Ma'), and `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma` are `comma_credential_run_ending_in_meng_keeps_the_suffix_comma` and `comma_lower_credential_run_ending_in_meng_keeps_the_suffix_comma`. The decision and its measurements are decisions/S7's #544 entry. +- **2026-09-27 (#544) — #540's two pending questions are answered, and the #540 bullet stands as it landed.** The chunked dotted spelling passes S2's period gate — `Wang M.Eng.` reads suffix 'M.Eng.' as `Wang M.A.` does, and as 2.0.0 through 2.3.0 read it — and the comma is kept: a credential run after a full name that ends in a member is the credential run by C1's name-word count (rules/C4 since #728) (`John Smith, PhD MEng` suffix 'PhD MEng'; `Jane Doe, MS LAc` suffix 'MS LAc' where #540 left title 'MS', given 'LAc', at the cost, accepted in decisions/S7, of `John Smith, Ms Ma` reading suffix 'Ms Ma'). The walk-stopping cost that bullet lists for a run ENDING in a member goes with them, by S2's company clause: `Mary Jones PhD MEng` and `Jane Doe MS LAc` read the whole run as a suffix again. Where the member LEADS the run nothing speaks for it, so `John Smith MEng PhD` keeps its cost. The `fix(#540)` ledger rules for the comma run and the dotted spelling are retired. `fix(#544)` rules classify the comma-run names; `Wang M.Eng.`, whose roles every release from 2.0.0 read, is classified by `feat(#449)`'s given-or-family report rule at 2.0.0 through 2.2.0 and by the dotted `fix(suffix-routing)` rule beside `Jack M.A.` at 1.4.0, and differs from 2.3.0 not at all. The case rows the #540 bullet cites were renamed with the readings they pin: `a_declined_ambiguous_pick_stops_the_walk` is `a_credential_in_front_anchors_a_declined_pick` (`abdul Smith Jr Ma`, suffix 'Jr Ma'), and `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma` are `comma_credential_run_ending_in_meng_keeps_the_suffix_comma` and `comma_lower_credential_run_ending_in_meng_keeps_the_suffix_comma`. The decision and its measurements are decisions/S7's #544 entry. diff --git a/nameparser/_pipeline/_comma.py b/nameparser/_pipeline/_comma.py index 6c50a5d7..bec04630 100644 --- a/nameparser/_pipeline/_comma.py +++ b/nameparser/_pipeline/_comma.py @@ -197,7 +197,8 @@ def decide(state: ParseState) -> ParseState: # suffix part as a comma would" -- behind a whole name its cores are # no word of the reading, and leave as group drops them in a tail; # behind one name word the core is a word, v1 having applied the - # delimiter to the suffix-comma form alone (C10's Accepted entry) + # delimiter to the suffix-comma form alone (C10's core-is-a-word + # Accepted entry) cores = delimiter_cores(pol.extra_suffix_delimiters) whole: bool | None = None core_idx: list[int] = [] diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index b0284b26..c636618f 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -623,7 +623,7 @@ def _group_segment(seg: tuple[int, ...], additional: int, # `a[0] == 0` the first TOKEN, which is v1's boundary. # seg_idx 0 and not after a family comma: a credential run # legitimately opens segment 1 ("Smith, Ph. D. - # Jr.", C1's listing form), and segment 0 before a + # Jr.", C8's credential run in the listing form), and segment 0 before a # comma is the family the comma named, where v1 # merges too ("Ph. D., John" reads last 'Ph. D.'). # diff --git a/nameparser/_types.py b/nameparser/_types.py index dc9833c2..9c2b0da0 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -467,7 +467,7 @@ class AmbiguityKind(StrEnum): #: site and this kind stays out of the way -- "Doe, John do" gives #: family ``do Doe`` and one ``PARTICLE_OR_GIVEN``, never two #: reports of one word. - #: Since #544 C4's name-word count reports its flip once over a + #: Since #544 C4's name-word count flips, reported once (C11), over a #: whole RUN after the comma ("John Smith, Ed Ma" reads suffix #: ``Ed Ma``), and a member an unambiguous credential in front of #: it speaks for (rules/S7) is a pick like a counted one, diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 03b917df..9f6e388d 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -2607,7 +2607,7 @@ def _check_cjk_shape_purity(self) -> None: "decision rides with it -- C2's structural flag already " "reports on the comma path, but reports what the parse " "could not recognize, not a fork it called " - "(rules/C11's exception, scoped to this class; P6's " + "(rules/C11's report, scoped to this class; P6's " "attachment fork has reported on a family-comma path " "since 2.3, e.g. 'Berg, Jan vd')", shape=2), @@ -2989,7 +2989,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="nobody writes initials as two dotted groups, so a " "second by-shape word makes the part the credential " - "run (rules/C5) -- reported, since here the pairs " + "run (rules/C5) -- reported (C11), since here the pairs " "speak only for each other and each is a word a reader " "takes for a name. 1.4.0 read given 'X.Y.', middle " "'P.Q.'", @@ -7320,7 +7320,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("comma-structure",), notes="the marker ends its part with nothing behind it, as it " "does before a typed comma, so it takes no clause " - "(rules/C10's Accepted entry, #549). 2.0.0 through " + "(rules/C10's maiden-marker Accepted entry, #549). 2.0.0 through " "2.3.0 read maiden 'Jones', stepping past the core"), Case("suffix_delimiter_core_that_survives_is_a_boundary_too", "Smith, MD - PhD - FACS", @@ -10001,7 +10001,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), policy=Policy(lenient_comma_suffixes=False), classification="fix(#603)", - notes="C1's strict knob still vetoes initial-shaped words as " + notes="C1's strict knob (C8 for the opened run) still vetoes initial-shaped words as " "SUFFIX words, so the numeral does not continue the run " "as a suffix word (#430's first draft read no policy at " "all and silently overrode the knob; " @@ -12055,7 +12055,7 @@ def _check_cjk_shape_purity(self) -> None: {"family": "王雅婷", "suffix": "議員"}, classification="fix(#671)", tolerated=True, notes="the fullwidth comma is a listing convention (W3, " - "best-effort); the word is vocabulary, so C1 reads the " + "best-effort); the word is vocabulary, so C8 reads the " "part after the comma as a credential run rather than " "reversing the name -- 2.4.2 gave it as the given name"), Case("zh_comma_degree_is_a_suffix", "陳怡君,醫學博士", diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index 7ff2a2da..ee57f7aa 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -150,7 +150,7 @@ def test_the_phd_merge_declines_at_the_head_of_a_name() -> None: # A suffix never begins a name (S2, and #371). The merge is what # made a leading "Ph." "D." a credential at all, and it emptied the # family doing it. Segment 0 only: after a family comma the run - # legitimately opens its segment, which C1 reads as a listing. + # legitimately opens its segment, which C8 reads as a listing's credential run. assert _piece_texts(_grouped("Ph. D. Van Johnson")) == \ [["Ph.", "D.", "Van Johnson"]] # the part after the comma is bound by group's head (#613), which diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index 4535e478..cae2830c 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -3210,7 +3210,7 @@ def test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin() -> None: place -- the maiden clause, the connective join and the entry boundary alike. The heads put the core in a TRAILING part only: before the first comma, or in the given part of the listing form, the core is - a word (C10's Accepted entry), and its comma twin a different + a word (C10's core-is-a-word Accepted entry), and its comma twin a different structure. RECORDED NEGATIVE CONTROL: at 061f02da, where the join and the