From 1d4c1de4b6b83c2c31fbb9348aafca3f88cf973e Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 17 Oct 2026 22:06:57 -0700 Subject: [PATCH 1/5] Split rules/S2 into S2 and S4-S9, one rule per piece of evidence (#682) S2 had grown to 324 lines holding seven rules. Each piece now has its own ID, statement, examples and Accepted clauses; no behavior moves (the 89 example lines are the same set, and corpus_rules.jsonl rebuilds byte-identical). - S2 a suffix word ends a name and never opens the string (keeps the trailing read-once clause the others rely on) - S4 the ambiguous acronym needs words to spare - S5 the given part's trailing slot after a family comma - S6 the case signal at the suffix slot - S7 the company of an unambiguous credential - S8 the credential's trailing run (#602) - S9 unlisted words that join the ambiguous class by shape S3 keeps its ID: rule IDs are never renumbered. Every citation in code, tests, other rules and AGENTS.md is retargeted by what it meant; four pointer citations became quoted ones so S5, S6, S7 and S9 have an implemented: list the citation check can hold. decisions/S2 stays the history of all seven until it is split in the next commit. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- docs/design/AGENTS.md | 2 +- docs/design/rules/C1-suffix-comma-decision.md | 20 +- .../rules/C2-parts-past-the-second-comma.md | 8 +- .../rules/H2-leading-abbreviation-title.md | 4 +- docs/design/rules/M2-maiden-marker-rule.md | 6 +- .../design/rules/P2-particles-join-forward.md | 8 +- docs/design/rules/P3-connectives.md | 4 +- .../rules/P6-trailing-orphan-particle.md | 14 +- .../R4-case-repair-reads-the-unjoined-mark.md | 4 +- docs/design/rules/README.md | 8 +- .../S2-case-signal-at-the-suffix-slot.md | 329 ------------------ ...-ends-a-name-and-never-opens-the-string.md | 90 +++++ ...edentials-written-with-interior-periods.md | 6 +- ...-ambiguous-acronym-needs-words-to-spare.md | 30 ++ ...arts-trailing-slot-after-a-family-comma.md | 69 ++++ .../S6-case-signal-at-the-suffix-slot.md | 35 ++ ...S7-company-of-an-unambiguous-credential.md | 67 ++++ .../rules/S8-credentials-trailing-run.md | 48 +++ ...-that-join-the-ambiguous-class-by-shape.md | 34 ++ .../W3-writers-divisions-are-respected.md | 4 +- nameparser/_lexicon.py | 2 +- nameparser/_pipeline/_assign.py | 25 +- nameparser/_pipeline/_classify.py | 7 +- nameparser/_pipeline/_comma.py | 3 +- nameparser/_pipeline/_group.py | 4 +- nameparser/_pipeline/_pieces.py | 15 +- nameparser/_pipeline/_vocab.py | 9 +- nameparser/_types.py | 4 +- nameparser/config/suffixes.py | 6 +- tests/v2/cases.py | 80 ++--- tests/v2/design_docs.py | 4 +- tests/v2/pipeline/test_comma.py | 4 +- tests/v2/pipeline/test_group.py | 6 +- tests/v2/pipeline/test_pieces.py | 4 +- tests/v2/pipeline/test_post_rules.py | 6 +- tests/v2/test_benchmark.py | 6 +- tests/v2/test_parser.py | 2 +- tests/v2/test_properties.py | 12 +- tests/v2/test_render.py | 4 +- 40 files changed, 528 insertions(+), 471 deletions(-) delete mode 100644 docs/design/rules/S2-case-signal-at-the-suffix-slot.md create mode 100644 docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md create mode 100644 docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md create mode 100644 docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md create mode 100644 docs/design/rules/S6-case-signal-at-the-suffix-slot.md create mode 100644 docs/design/rules/S7-company-of-an-unambiguous-credential.md create mode 100644 docs/design/rules/S8-credentials-trailing-run.md create mode 100644 docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md diff --git a/AGENTS.md b/AGENTS.md index 5992b8ff..b686bf8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Three committed contributor docs carry the parser's normative rules and their re - **decisions/** — the ADR-style record, one file per key: dated entries, Declined: (rejected WITH evidence), Excluded: (what must stay out of a wordlist and why), Open: (issue links, never restated; also keyed to a vocabulary set for contested memberships — the right-rule-wrong-set class), 3-0-reevaluations (append whenever a design cites 1.4 parity as load-bearing). - **mechanisms.md** — problem-shape catalog with citable contract statements; stage-attribution claims in comments must cite an entry verbatim, never restate it in fresh words. -**Each rule and decisions entry is its own named file** (`rules/S2-case-signal-at-the-suffix-slot.md`, `decisions/vocabulary-collisions.md`), and each folder's README.md indexes them; a new rule or a new decisions key is a new file plus its index line, which `tests/v2/test_design_docs.py` holds to the file's heading and name, and tests/v2/design_docs.py is the one module that knows the layout (#713). Citations carry the ID only, never the name, so a file can be renamed freely. **When you cite a rule or decision in chat, a commit or a PR, give its name with its ID** — "S2 (the case signal at the suffix slot)" — since the ID alone tells the reader nothing about which rule it is. +**Each rule and decisions entry is its own named file** (`rules/S6-case-signal-at-the-suffix-slot.md`, `decisions/vocabulary-collisions.md`), and each folder's README.md indexes them; a new rule or a new decisions key is a new file plus its index line, which `tests/v2/test_design_docs.py` holds to the file's heading and name, and tests/v2/design_docs.py is the one module that knows the layout (#713). Citations carry the ID only, never the name, so a file can be renamed freely. **When you cite a rule or decision in chat, a commit or a PR, give its name with its ID** — "S6 (the case signal at the suffix slot)" — since the ID alone tells the reader nothing about which rule it is. **Same-PR amendment rule.** Any PR that changes or clarifies parser behavior — or the boundaries around a documented rule, since a neighboring change can invalidate a rule's stated limits without touching its code — amends the rules in the same diff (the doc diff is part of the reviewable change, like tests). A change that resolves or reverses a design question adds a decisions entry; a new reusable pattern adds a mechanisms.md entry; a fixed deviation removes its `deviates:` marker in the same PR (the examples test forces this). Issues proposing behavior changes should be drafted in rule shape — rationale, statement, examples with boundaries, accepted consequences, open questions, exclusions — so landing an accepted proposal is a copy, not a rewrite. @@ -255,7 +255,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **Unknown-key attribute access on `TupleManager`/`RegexTupleManager` warns (1.4, #256) — 2.0 note: the warning became a hard `AttributeError` naming the known keys (shim `TupleManager`); the paragraph below describes the deleted v1 machinery and applies only when reading v1 history.** — a key not currently in the dict emits `DeprecationWarning` naming the miss and the known keys (`_warn_unknown_key`), before falling back to the same `None`/`EMPTY_REGEX` default as before; `.get()` stays silent. Dunder probes (`__deepcopy__`) still raise `AttributeError` outright, and single-underscore probes (`_repr_html_`, IPython's `_ipython_canary_method_should_not_exist_`, etc.) are excluded from the warning too — no real config key starts with `_`, so both guards just keep protocol/introspection probes from misfiring as "typo" warnings. This means internal parser code that reads `self.C.regexes.` unconditionally (e.g. `squash_bidi`'s `bidi`) now warns if a caller's custom `regexes` dict omits that key — a previously-silent partial-override pattern is on the same deprecation path as an actual typo. -**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). Then check the word against real names, not memory: `uv run python tools/name_usage.py ` counts its bearers as a given name and as a family name (US Census surnames, SSA given names, Wikidata with countries of citizenship). Pass a name as it is written (`McDonald`): Wikidata labels match exactly, and no rule rebuilds an internal capital from `mcdonald`. It reports and does not decide — read the counts against the position the set's claim acts on. A word that would peel GLUED (`GLUED_HONORIFICS`) asks a different question, whether any name ENDS in it, and for a CJK word the tool answers that too: an ends-with row from a cached index of Wikidata's CJK name labels (built on first use; 33 minutes on 2026-10-17), plus the names the word ends across the family/given join (阿部 + 長 for 部長). Read the tool's hits by item before counting them, because labels include transcriptions and mislabels: #707's one hit for 二世 was 厄德二世, the given name Odo with a regnal numeral in its Chinese label, borne by 109 Western people named Odo. Then ask a third question the tool cannot: does a common longer word end in it? The peel matches the end of a token, spaced or glued, so a listed tail splits every unlisted compound that ends in it — #707 vetted 議員, 社長 and 医学博士 against names and its review found 衆議院議員, 副社長 and 獣医学博士 cut at the tail (`田中 副社長` → given 副); no corpus held a compound office word until #707's rules examples added two, so the snapshot was silent about the class. List the common compounds whole or record the split as a cost. And check the predicates that test a member's SPELLING rather than its membership: they encode what the set's members happened to look like when they were written. `_pieces.starts_a_credential_run` implemented rules/S2's "Latin" as `isascii()`, exact until #661 added `mlađi`, which would have read `mlađi Jones` differently from `stariji Jones`; the design-docs review caught it, after a first draft had credited the test to the wrong clause of S2 and proposed deleting it on a mutation that moved nothing because nothing pinned it. (#679 then dropped the script test on purpose, after measuring what it protected.) Before adding the first member of a new script, or the first accented one, grep the set's consumers for `isascii`, `isalpha` and script tests, and read the rule each one implements. The same goes for the first member holding PUNCTUATION: #700 added `d'` and `az-`, the parse read them right and every snapshot move was an intended one, and `capitalized()` still wrote `D' Ivoi`, because case repair splits a token at its apostrophe or hyphen and asks the vocabulary of the stem. `capitalized()` is invisible to the snapshot (`initials()` is recorded there), so parse such a member's names through `capitalized()` and `capitalized(force=True)` in each case before calling it done. +**Adding a word to a config set** — first check the *other* sets for the same word (grep `nameparser/config/` or intersect the sets in a `python3 -c`). Real overlaps exist: `do`/`st`/`mc` ∈ `PARTICLES` ∩ `TITLES`/`SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; dropping `abd` from `BOUND_GIVEN_NAMES`). Then check the word against real names, not memory: `uv run python tools/name_usage.py ` counts its bearers as a given name and as a family name (US Census surnames, SSA given names, Wikidata with countries of citizenship). Pass a name as it is written (`McDonald`): Wikidata labels match exactly, and no rule rebuilds an internal capital from `mcdonald`. It reports and does not decide — read the counts against the position the set's claim acts on. A word that would peel GLUED (`GLUED_HONORIFICS`) asks a different question, whether any name ENDS in it, and for a CJK word the tool answers that too: an ends-with row from a cached index of Wikidata's CJK name labels (built on first use; 33 minutes on 2026-10-17), plus the names the word ends across the family/given join (阿部 + 長 for 部長). Read the tool's hits by item before counting them, because labels include transcriptions and mislabels: #707's one hit for 二世 was 厄德二世, the given name Odo with a regnal numeral in its Chinese label, borne by 109 Western people named Odo. Then ask a third question the tool cannot: does a common longer word end in it? The peel matches the end of a token, spaced or glued, so a listed tail splits every unlisted compound that ends in it — #707 vetted 議員, 社長 and 医学博士 against names and its review found 衆議院議員, 副社長 and 獣医学博士 cut at the tail (`田中 副社長` → given 副); no corpus held a compound office word until #707's rules examples added two, so the snapshot was silent about the class. List the common compounds whole or record the split as a cost. And check the predicates that test a member's SPELLING rather than its membership: they encode what the set's members happened to look like when they were written. `_pieces.starts_a_credential_run` implemented the credential run's "Latin" (rules/S8 now, part of S2 until #682 split it) as `isascii()`, exact until #661 added `mlađi`, which would have read `mlađi Jones` differently from `stariji Jones`; the design-docs review caught it, after a first draft had credited the test to the wrong clause of S2 and proposed deleting it on a mutation that moved nothing because nothing pinned it. (#679 then dropped the script test on purpose, after measuring what it protected.) Before adding the first member of a new script, or the first accented one, grep the set's consumers for `isascii`, `isalpha` and script tests, and read the rule each one implements. The same goes for the first member holding PUNCTUATION: #700 added `d'` and `az-`, the parse read them right and every snapshot move was an intended one, and `capitalized()` still wrote `D' Ivoi`, because case repair splits a token at its apostrophe or hyphen and asks the vocabulary of the stem. `capitalized()` is invisible to the snapshot (`initials()` is recorded there), so parse such a member's names through `capitalized()` and `capitalized(force=True)` in each case before calling it done. **Before adding a short/common word to `PARTICLES` globally**, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (`Park In Hwan`, `Nguyen To Nga`), and Western names put a bare initial there (`John V. Smith`). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for `to`/`in`/`an`/`ten`/`then` and bare `v` this way (PR #191). @@ -295,7 +295,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_ **`_normalize` must reach a fixed point** — storage and match-time share the one fold, and `Lexicon.__setstate__` re-validates, so a value that changes on re-normalization changes under its owner. `strip().strip(".")` alone is not idempotent (`'. a .'` → `' a '` → `'a'`). The loop is the fix; keep any new stripping inside it. **Anything built on `_normalize` must converge too** — `_fold_words` runs `_normalize` per word and DROPS the words that fold away (`_title_key` is that list space-joined, and `_run_addresses_by_given` reads the list itself, so its last-word arm is the last word of the FOLDED key by construction); keeping the empty slot stored `'lt .'` as `'lt '`, a key match-time can never rebuild (so the entry is silently inert) and `__setstate__` rejects on the next round-trip as "not written by this version". -**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over a table of shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — every other unit is pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured, M2's clause view only via `maiden_clause`, the one unit holding a marker (until #601 its order mattered, `MA nee ` reaching nothing; since #601 either order reaches the take) — and connective RUN LENGTH only via `link_run`, whose unit has to be MIXED CASE and hold a connective of the generational class: `i und ` reads the letter as an initial and reaches nothing where `i Und ` reaches everything, and S2's anchor pass only via `credential_run`, whose unit has to hold a Title-case member behind an unambiguous credential: `PhD MA ` reads the same fields and never asks the pass, the capitals deciding each member first); measure before pruning one. **A shape whose input needs a PREFIX cannot be a `_SHAPES` row at all**, since that table repeats a unit and nothing else — a maiden clause needs a name word and a marker before the run it is about, and `"nee i Und " * n` reaches the clause rule not at all, measuring the identical ratio on a broken tree and a fixed one. That one is `test_a_clause_link_run_does_not_cost_quadratically`, which builds its own input and counts FRAMES. A prefixed shape whose cost is Python-level joins `_REREAD_SHAPES` beside it — (input builder, the one function the defect re-runs, reachability probe) rows counted in that function's frames at 8 units against 32 — which holds #558's trailing fixed point, its maiden-clause twin, and #559's particle chain, a shape that grows at both ends and so has no unit to repeat. A prefixed shape whose cost is C-level work — a list scanned by `in`, a tuple re-hashed as a dict key — emits no frame for that count to see, so it goes in `_PREFIXED_SHAPES` instead: (prefix, unit, reachability probe) rows timed on the clock like `_SHAPES`, at a base of their own recorded beside the table (#553, whose run shape read 5.97× once at base 800 on the pre-fix tree, under the bound; py3.11, 2026-09-28). **That table skips under a line tracer** (`sys.settrace`, coverage's core below py3.14), which slows every Python line and not the C-level scan, diluting the quadratic below the bound (5.46–5.52× broken at base 1600 under `coverage run` on py3.11, same date). The `sys.monitoring` core coverage uses from 3.14 does not dilute it (8.87–10.91× broken), and CI collects coverage in its 3.14 build job alone, so every CI job runs the table; `ja-extra` also sets `NAMEPARSER_REQUIRE_CLOCK_GUARDS` so that a line tracer there fails the rows instead of skipping them. **Coverage is collected on ONE interpreter, the one that uploads it** (`COVERAGE_ON` in `.github/workflows/python-package.yml`): the package has no version-conditional code, so one report stands for all of them, and under a line tracer the property grids in `tests/v2/test_properties.py` had cost the 3.12 job most of its ~12 minutes (measured 2026-09-29: the three #397 grids take 139s under coverage's settrace core on 3.12, 24s under its sys.monitoring core, 17s with none). **A shape the CLOCK cannot reach needs a FRAME-count guard instead**, which is the second scaling test in that file (`test_a_trailing_credential_run_does_not_cost_exponentially`, #531): where the defect is an exponential rather than a quadratic, the input length that separates the curves on a timing test does not finish, so the guard counts frames over 8 units against 16 and bounds THAT ratio. One pair does not see every curve, and the fix round for #531 measured why: at 2× the input the per-member LINEAR work swamps a quadratic (2.08× for a genuine one against 1.73× clean), so that pair guards the exponential alone and a second, longer pair — 16 against 64, where the same quadratic reads 7.42× against 3.53× clean — is what can see one. Assert them in that order: an exponential never returns from the longer run, so the cheap pair has to have failed first. **A frame guard counts ONE function, so it goes blind when the code it watches stops calling that function.** #563's paired-initials guard counted `_normalize`, which segment's scan reached; #613 moved the scan into `_comma.decide`, where it calls `is_suffix_piece`, and the quadratic came back while the guard read 3.6× and passed (review caught it: 120 → 1,620 calls for 4× the input). When code under a frame guard moves, re-take the guard's negative control on the moved code before trusting a pass. Frame counts do not move under load, so this shape needs no repeated-run calibration — but it does need the same reachability assertion `_POLICY_SHAPES` rows carry, since the walk under measurement runs only while every unit still reads as a credential. A stage gated on an opt-in `Policy` field needs a `_POLICY_SHAPES` entry instead, since bare `parse()` never enters it — and that table's rows carry a **reachability probe** run before the measurement, because a precedence change can quietly stop the shape reaching the stage and leave a green test measuring a no-op (`_POLICY_SHAPES` is also asserted non-empty: an empty `parametrize` is a skip, not a failure, so deleting its last row would retire the guard silently). +**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over a table of shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — every other unit is pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured, M2's clause view only via `maiden_clause`, the one unit holding a marker (until #601 its order mattered, `MA nee ` reaching nothing; since #601 either order reaches the take) — and connective RUN LENGTH only via `link_run`, whose unit has to be MIXED CASE and hold a connective of the generational class: `i und ` reads the letter as an initial and reaches nothing where `i Und ` reaches everything, and S7's anchor pass only via `credential_run`, whose unit has to hold a Title-case member behind an unambiguous credential: `PhD MA ` reads the same fields and never asks the pass, the capitals deciding each member first); measure before pruning one. **A shape whose input needs a PREFIX cannot be a `_SHAPES` row at all**, since that table repeats a unit and nothing else — a maiden clause needs a name word and a marker before the run it is about, and `"nee i Und " * n` reaches the clause rule not at all, measuring the identical ratio on a broken tree and a fixed one. That one is `test_a_clause_link_run_does_not_cost_quadratically`, which builds its own input and counts FRAMES. A prefixed shape whose cost is Python-level joins `_REREAD_SHAPES` beside it — (input builder, the one function the defect re-runs, reachability probe) rows counted in that function's frames at 8 units against 32 — which holds #558's trailing fixed point, its maiden-clause twin, and #559's particle chain, a shape that grows at both ends and so has no unit to repeat. A prefixed shape whose cost is C-level work — a list scanned by `in`, a tuple re-hashed as a dict key — emits no frame for that count to see, so it goes in `_PREFIXED_SHAPES` instead: (prefix, unit, reachability probe) rows timed on the clock like `_SHAPES`, at a base of their own recorded beside the table (#553, whose run shape read 5.97× once at base 800 on the pre-fix tree, under the bound; py3.11, 2026-09-28). **That table skips under a line tracer** (`sys.settrace`, coverage's core below py3.14), which slows every Python line and not the C-level scan, diluting the quadratic below the bound (5.46–5.52× broken at base 1600 under `coverage run` on py3.11, same date). The `sys.monitoring` core coverage uses from 3.14 does not dilute it (8.87–10.91× broken), and CI collects coverage in its 3.14 build job alone, so every CI job runs the table; `ja-extra` also sets `NAMEPARSER_REQUIRE_CLOCK_GUARDS` so that a line tracer there fails the rows instead of skipping them. **Coverage is collected on ONE interpreter, the one that uploads it** (`COVERAGE_ON` in `.github/workflows/python-package.yml`): the package has no version-conditional code, so one report stands for all of them, and under a line tracer the property grids in `tests/v2/test_properties.py` had cost the 3.12 job most of its ~12 minutes (measured 2026-09-29: the three #397 grids take 139s under coverage's settrace core on 3.12, 24s under its sys.monitoring core, 17s with none). **A shape the CLOCK cannot reach needs a FRAME-count guard instead**, which is the second scaling test in that file (`test_a_trailing_credential_run_does_not_cost_exponentially`, #531): where the defect is an exponential rather than a quadratic, the input length that separates the curves on a timing test does not finish, so the guard counts frames over 8 units against 16 and bounds THAT ratio. One pair does not see every curve, and the fix round for #531 measured why: at 2× the input the per-member LINEAR work swamps a quadratic (2.08× for a genuine one against 1.73× clean), so that pair guards the exponential alone and a second, longer pair — 16 against 64, where the same quadratic reads 7.42× against 3.53× clean — is what can see one. Assert them in that order: an exponential never returns from the longer run, so the cheap pair has to have failed first. **A frame guard counts ONE function, so it goes blind when the code it watches stops calling that function.** #563's paired-initials guard counted `_normalize`, which segment's scan reached; #613 moved the scan into `_comma.decide`, where it calls `is_suffix_piece`, and the quadratic came back while the guard read 3.6× and passed (review caught it: 120 → 1,620 calls for 4× the input). When code under a frame guard moves, re-take the guard's negative control on the moved code before trusting a pass. Frame counts do not move under load, so this shape needs no repeated-run calibration — but it does need the same reachability assertion `_POLICY_SHAPES` rows carry, since the walk under measurement runs only while every unit still reads as a credential. A stage gated on an opt-in `Policy` field needs a `_POLICY_SHAPES` entry instead, since bare `parse()` never enters it — and that table's rows carry a **reachability probe** run before the measurement, because a precedence change can quietly stop the shape reaching the stage and leave a green test measuring a no-op (`_POLICY_SHAPES` is also asserted non-empty: an empty `parametrize` is a skip, not a failure, so deleting its last row would retire the guard silently). **Expected-failure tests use `@pytest.mark.xfail`** — the conftest parametrized fixture breaks `@unittest.expectedFailure`; always use `@pytest.mark.xfail` instead. diff --git a/docs/design/AGENTS.md b/docs/design/AGENTS.md index f8837f2f..ddb3c19e 100644 --- a/docs/design/AGENTS.md +++ b/docs/design/AGENTS.md @@ -6,7 +6,7 @@ rules/ states intended parsing behavior and is NORMATIVE; decisions/ is the date **Starting a design: pin the negative space first.** A spec's "X is not supported" is a claim about today's parser; hold it with a case row for X written BEFORE the implementation, so a row that passes unexpectedly kills the premise before any code exists. The 2026-07-30 comma-suffix spec did this for one item (`suffix_phrase_is_comma_scoped`) and not for its central premise, which is the half that cost a full plan (#291). -**Rethinking a rule that grew by patching.** Three signs together mean the next fix should be a rethink, not another patch: the rule works by scanning and stopping; each later fix added a stop, an exception or a check on one; and a guard exists whose job is to model what another stage would do. The third sign is the decisive one — a rule that must predict other rules' behavior to stay correct is defending an invariant it should be built from. #601 is the worked case. Five issues (#424, #533, #535, #538, #549) had each added a stop or a release check to M2, and its decisions.md section reached about 13,000 words. The rethink (mechanisms.md#READ-WITHOUT-THEN-BIND, #LICENSED-BY-NEIGHBOR) deleted the release machinery, cut the rule's statement to 883 words (counted 2026-10-04), and turned the invariant from a checked property into the construction. The rethink was drafted in rule shape and prototyped in seven variants behind one switch before anything landed (#601's comments); the variants that failed did so on measured counts, not on review opinion. Rule length alone is not the signal: S2 and R4 have each gathered 35 dated decisions entries, and R4's are mostly vocabulary and rendering cases rather than added stops (counted 2026-10-04). +**Rethinking a rule that grew by patching.** Three signs together mean the next fix should be a rethink, not another patch: the rule works by scanning and stopping; each later fix added a stop, an exception or a check on one; and a guard exists whose job is to model what another stage would do. The third sign is the decisive one — a rule that must predict other rules' behavior to stay correct is defending an invariant it should be built from. #601 is the worked case. Five issues (#424, #533, #535, #538, #549) had each added a stop or a release check to M2, and its decisions.md section reached about 13,000 words. The rethink (mechanisms.md#READ-WITHOUT-THEN-BIND, #LICENSED-BY-NEIGHBOR) deleted the release machinery, cut the rule's statement to 883 words (counted 2026-10-04), and turned the invariant from a checked property into the construction. The rethink was drafted in rule shape and prototyped in seven variants behind one switch before anything landed (#601's comments); the variants that failed did so on measured counts, not on review opinion. Rule length alone is not the signal: S2 (before #682 split it into S2 and S4–S9) and R4 had each gathered 35 dated decisions entries, and R4's are mostly vocabulary and rendering cases rather than added stops (counted 2026-10-04). **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. diff --git a/docs/design/rules/C1-suffix-comma-decision.md b/docs/design/rules/C1-suffix-comma-decision.md index b905c8d5..355f7978 100644 --- a/docs/design/rules/C1-suffix-comma-decision.md +++ b/docs/design/rules/C1-suffix-comma-decision.md @@ -42,7 +42,7 @@ C1. Rationale: a credential run after the comma means the name is in 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 (S2). Paired initials are the exception to + 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 @@ -52,10 +52,10 @@ C1. Rationale: a credential run after the comma means the name is in 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 S2's + 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 S2 reads them + 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 @@ -88,8 +88,8 @@ C1. Rationale: a credential run after the comma means the name is in 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 S2's company asks whether it speaks for the word - behind it, which a particle never does; the run S2 says such a + 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 @@ -97,7 +97,7 @@ C1. Rationale: a credential run after the comma means the name is in 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 (S2), and the part reads as the credential run on that + 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 @@ -121,7 +121,7 @@ C1. Rationale: a credential run after the comma means the name is in 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 S2 reads the words + 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, @@ -132,7 +132,7 @@ C1. Rationale: a credential run after the comma means the name is in '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 (S2), which is a second decision about a second word and never + 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. @@ -151,7 +151,7 @@ C1. Rationale: a credential run after the comma means the name is in 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 S2's run, which S2 bounds (the split 'Ph. D.' among them), + 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 @@ -348,5 +348,5 @@ C1. Rationale: a credential run after the comma means the name is in 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, S2, S3, M2 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py + history: decisions/C1 · interacts: H1, H2, H6, P1, P2, P3, P5, P6, W3, S3, M2, S5, S6, S7, S8 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.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 4b4395fc..620499cc 100644 --- a/docs/design/rules/C2-parts-past-the-second-comma.md +++ b/docs/design/rules/C2-parts-past-the-second-comma.md @@ -20,14 +20,14 @@ C2. Rationale: text beyond the recognized comma parts should be A part the parse reads as a credential run by some route other than the suffix vocabulary is recognized and is not flagged: a run of ambiguous acronyms whose written case leans credential - (S2), and a run every word of which is an unlisted DOTTED word + (S6), and a run every word of which is an unlisted DOTTED word the position reads as a credential (S3). This is the one place the ambiguous class QUIETS a report rather than adding one, and it is narrow in two ways that the examples below pin. A member whose written case does not lean CREDENTIAL keeps the flag, whether the name is written in one case so that nothing leans at all, or the member is written the way a name is written. And - S2's other by-shape half, the unlisted all-caps word, does not + S9's other by-shape half, the unlisted all-caps word, does not reach here in any setting: the shape a tail segment is recognized by is the dotted one alone. A part the parse consumes wholly as suffixes raises no report @@ -39,7 +39,7 @@ C2. Rationale: text beyond the recognized comma parts should be 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 - first word after the comma), S2's report on each word a credential + first word after the comma), S8'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 this rule's flag. @@ -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/S2 · interacts: C1, H5, P2, P5, S2, S3 · implemented: nameparser/_pipeline/_segment.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py + history: decisions/C1, decisions/S2 · interacts: C1, 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/H2-leading-abbreviation-title.md b/docs/design/rules/H2-leading-abbreviation-title.md index 9d448b30..9dfeeb5d 100644 --- a/docs/design/rules/H2-leading-abbreviation-title.md +++ b/docs/design/rules/H2-leading-abbreviation-title.md @@ -16,7 +16,7 @@ H2. Rationale: before a name, an abbreviation is almost always a 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, S2 and S3 read (#652), so such a name parses the + shapes C1, 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." @@ -55,5 +55,5 @@ H2. Rationale: before a name, an abbreviation is almost always a not open: the vocabulary decides, and "Esq." is the postnominal it is. "Smith, Esq." → suffix="Esq." - history: decisions/H2 · interacts: C1, S2, S3, P4, H5, H6, W3, W4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py + 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 diff --git a/docs/design/rules/M2-maiden-marker-rule.md b/docs/design/rules/M2-maiden-marker-rule.md index cfac29ee..377e0fd0 100644 --- a/docs/design/rules/M2-maiden-marker-rule.md +++ b/docs/design/rules/M2-maiden-marker-rule.md @@ -29,7 +29,7 @@ M2. Rationale: a maiden marker announces that what follows it is the the clause ends at the first suffix word, and the words from it on read as that part reads them. Where a trailing rule reads the part, a member of the ambiguous - class (S2) that ends the maiden name is reported where the clause + class (S4) that ends the maiden name is reported where the clause keeps it, and one in the run is reported where the run is read, so no word is reported twice and none goes unreported. Before a family comma no rule reads it, and the member the clause keeps @@ -140,7 +140,7 @@ M2. Rationale: a maiden marker announces that what follows it is the it. "Jane Doe (nee Smith Prof.)" → title="Prof." Accepted: a marker behind a credential is an ordinary word, and - the credential's run (S2) takes it in with everything after it. + 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 · 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, S4, S8 · implemented: nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py diff --git a/docs/design/rules/P2-particles-join-forward.md b/docs/design/rules/P2-particles-join-forward.md index 2649a706..107945c0 100644 --- a/docs/design/rules/P2-particles-join-forward.md +++ b/docs/design/rules/P2-particles-join-forward.md @@ -14,7 +14,7 @@ P2. Rationale: a particle is written as part of the surname it acronym written Title-case in a mixed-case name ends nothing and joins the chain as any name word does, words to spare or not, and the fork the chain called is reported there exactly as it is - where no particle stands (S2) — + where no particle stands (S4) — a maiden marker takes the words after it (M2), or the name ends. The final group reads as @@ -58,14 +58,14 @@ P2. Rationale: a particle is written as part of the surname it word, and the peel takes it; where it continues a prefix run, the run takes it as a particle, as P6 reads it after a comma — and there the chain reports nothing, the particle reading being - P6's fork rather than S2's. + P6's fork rather than S4's. The same carve-out covers `do`, the one word that is both a particle and an AMBIGUOUS credential acronym: where a particle run takes it, the word is the run's and the chain reports nothing, whatever case it is written in — so `Anh van Do` is silent where `Anh Do`, with no particle standing, reports. A word that is both belongs to the particle run and to P6's - fork, not to S2's. + fork, not to S4's. "John Smith Mc V" → suffix="Mc V" "John van Mc" → family="van Mc" "anh van do" → family="van do" · boundary @@ -76,5 +76,5 @@ P2. Rationale: a particle is written as part of the surname it (#132's ask) has it as the surnames view rather than the family field. "Vincent van Gogh van Beethoven" → surnames="van Gogh van Beethoven" - history: decisions/P2 · interacts: P1, P4, H5, M2, S2, C2 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py + history: decisions/P2 · interacts: P1, P4, H5, M2, S2, C2, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py diff --git a/docs/design/rules/P3-connectives.md b/docs/design/rules/P3-connectives.md index c40ef1b5..873e9ee4 100644 --- a/docs/design/rules/P3-connectives.md +++ b/docs/design/rules/P3-connectives.md @@ -53,7 +53,7 @@ P3. Rationale: connective words ("y", "of the") bind name words into marker the pass declines and leaves as a word (M2) is a word, and 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 (S2) and the post-comma slot + 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 once and read where each of them stands. "Juan y Eva Garcia" → given="Juan y Eva" @@ -149,5 +149,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, S2, C1 · implemented: 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, C1, S6 · implemented: 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 401025d9..841637b5 100644 --- a/docs/design/rules/P6-trailing-orphan-particle.md +++ b/docs/design/rules/P6-trailing-orphan-particle.md @@ -17,7 +17,7 @@ P6. Rationale: a particle ending the name has nothing to link ending the name attaches to that family name and is written before it — provided at least one given word remains, so that a name whose only given word is the particle keeps it (the - words-to-spare test S2 applies to ambiguous suffixes). A + words-to-spare test S4 applies to ambiguous suffixes). A post-nominal is written BEHIND the particle in this listing, so it does not end the name for this purpose: the run is found by looking past trailing words that hold no name — unless such a @@ -32,9 +32,9 @@ P6. Rationale: a particle ending the name has nothing to link than one case, reads as the credential and this attachment stands down — unless a particle stands immediately in front of it, the two being one particle run by then, which this rule takes whole. - The second is S2's company: such a word standing behind an + The second is S7's company: such a word standing behind an unambiguous credential in one run of suffix words reads as the - credential in any spelling and any case, reported as S2's + credential in any spelling and any case, reported as S4's credential fork. The third is a lone word of both the particle and the UNAMBIGUOUS suffix vocabulary standing INSIDE a run read as post-nominals — one in front of it and another behind, a @@ -43,12 +43,12 @@ P6. Rationale: a particle ending the name has nothing to link tussenvoegsel stands right behind the given name, so a post-nominal between them says the word is not one. It does not end the name, and keeps the - post-nominal reading in any case, reported as S2's credential + post-nominal reading in any case, reported as S4's credential fork. With nothing behind it, it ends the name and attaches, and two such words side by side are one particle run, which this rule takes whole. Every other spelling of such a word attaches as it did before, and the kind rule below gives it this rule's - particle fork rather than S2's credential one. In a name written + particle fork rather than S4's credential one. In a name written wholly in one case, with nothing in front of the word to speak for it, the two readings cannot be told apart and the particle keeps it, which is right about a Portuguese record and wrong @@ -166,7 +166,7 @@ P6. Rationale: a particle ending the name has nothing to link and so does "Smith vd". The comma is the contrast, and it is where the precedence stated above acts, "Berg, Jan vd" reading family "vd Berg". `do` follows `de` rather than `mc`, sitting - in the AMBIGUOUS acronym half, where S2's words-to-spare test + in the AMBIGUOUS acronym half, where S4's words-to-spare test leaves a two-word name its family. "Jong Anke de" → family="de" Accepted: the precedence over S2 is stated for the shape, so it @@ -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 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py + 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 diff --git a/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md b/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md index 2c741561..093ce3c4 100644 --- a/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md +++ b/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md @@ -57,7 +57,7 @@ R4. Rationale: case repair is a display concern, applied only on does not carry is an initialism, so a single-case word the parse put in the suffix role from the acronym vocabulary, or read as a credential by its dotted shape alone (S3) or by its capitals - (S2), repairs to its all-caps spelling rather than a title-cased + (S6), repairs to its all-caps spelling rather than a title-cased one, and that repair outranks the Mac/Mc convention where a word fits both (MCSE, not McSe). A roman numeral the parse put in the suffix role is @@ -186,5 +186,5 @@ R4. Rationale: case repair is a display concern, applied only on both of the parsed name's views: a middle revised to "e-f" repairs to "E-F" as the parsed name does, where splicing the same text in gives "e-F". - history: decisions/R4 · interacts: P3, R2, R3, R5, S3 · implemented: nameparser/_render.py, nameparser/_lexicon.py + history: decisions/R4 · interacts: P3, R2, R3, R5, S3, S6 · implemented: nameparser/_render.py, nameparser/_lexicon.py diff --git a/docs/design/rules/README.md b/docs/design/rules/README.md index ee145058..425d70e8 100644 --- a/docs/design/rules/README.md +++ b/docs/design/rules/README.md @@ -51,8 +51,14 @@ The marker's unit is the whole rule, so a clause that reaches past the tolerated ## [Suffixes: generational & credentials (S)](S-suffixes-generational-and-credentials.md) - [S1 — a bracketed credential is not a nickname](S1-bracketed-credential-is-not-a-nickname.md) -- [S2 — the case signal at the suffix slot](S2-case-signal-at-the-suffix-slot.md) +- [S2 — a suffix word ends a name and never opens the string](S2-suffix-word-ends-a-name-and-never-opens-the-string.md) - [S3 — credentials written with interior periods](S3-credentials-written-with-interior-periods.md) +- [S4 — the ambiguous acronym needs words to spare](S4-ambiguous-acronym-needs-words-to-spare.md) +- [S5 — the given part's trailing slot after a family comma](S5-given-parts-trailing-slot-after-a-family-comma.md) +- [S6 — the case signal at the suffix slot](S6-case-signal-at-the-suffix-slot.md) +- [S7 — the company of an unambiguous credential](S7-company-of-an-unambiguous-credential.md) +- [S8 — the credential's trailing run](S8-credentials-trailing-run.md) +- [S9 — unlisted words that join the ambiguous class by shape](S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md) ## [Nicknames & quoted names (N)](N-nicknames-and-quoted-names.md) diff --git a/docs/design/rules/S2-case-signal-at-the-suffix-slot.md b/docs/design/rules/S2-case-signal-at-the-suffix-slot.md deleted file mode 100644 index c8cc3696..00000000 --- a/docs/design/rules/S2-case-signal-at-the-suffix-slot.md +++ /dev/null @@ -1,329 +0,0 @@ -# S2 — the case signal at the suffix slot - -S2. Rationale: generational suffixes and credentials are recognized - by vocabulary; an acronym that is also an ordinary name is only - unmistakably a credential when its periods are written. - A suffix never OPENS THE STRING: position outranks the - vocabulary match, so a suffix-shaped word written before anything - else reads as whatever its position and shape make it — a title - where it wears the abbreviation shape (H2), an ordinary name word - otherwise — and never as a credential belonging to a name that - has not been written yet. The string, not the name: a title, a - nickname or a bracketed clause standing first leaves the - credential reading intact, which is the ONE place this document - 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 - title run opening it (H6). - A trailing word of the suffix vocabulary reads as a suffix — - generational forms and credential acronyms alike, and an - ambiguous acronym written with its periods, one after each - letter or one after each of two or more letter chunks, counts - unambiguously; a single trailing period is the abbreviation - shape any word can wear and does not. The run at a name's - trailing slot is read ONCE, once the connectives have joined (P3) - and before the particle chain (P2) and the bound given-name join - (P5) are made, counting a particle run and the name words the - chain will take with it as the one name word it will make of them - — the words both particles and suffix vocabulary standing straight - behind a particle among them, while a word the reading weighs - counts as a word of its own wherever it stands; inside a - credential run a word stands on its own, a title word there - still a title — and neither - join reaches a word the run took. A - BARE ambiguous acronym is consumed only when the name has words - to spare — as the second of two words it stays the family - name — and at the slots that report, either reading carries the - ambiguity flag. Those slots are the trailing slot of a name, the - first slot after a family comma, the trailing slot of the GIVEN - part after that comma, and the trailing slot of a maiden - marker's clause (M2). A part beyond the second comma is not one - of them; what such a part does report is C2's to say. A word - this document - says is READ at one of those slots is not always a word one of - them reports: where a reading moves a word out of the slot that - asked about it, what reports is the slot it lands in, and that - may be none — M2 states the case where a clause does it. - After a family comma, a word of this class ending the GIVEN part - is read as the comma-less spelling reads a word ending the name, - and the count is not what decides it there. The comma has already - named the family and the first name word after it is the given - name, so the words to spare are there by construction and the - count says nothing — unless that first word is a never-given - particle, which is no given name and needs the next name word to - attach to (P1): that word is the surname the particle heads, not - a word to spare, so it reads as a name unless its capitals in a - mixed-case name say credential, as they do with no words to spare - anywhere: `SMITH, VD MA` reads as `Smith, vd Ma` does, `Smith, de - MA` keeps suffix `MA`, and `SMITH, VAN MA`, whose particle can be - a given name, keeps it and reads suffix `MA`. Otherwise a word - that ENDS that part reads as the - credential unless its WRITING says otherwise, and a word that - does not end it is never asked. "Ending the given part" reaches - past the credentials behind it and past a trailing title, which - is transparent to this reading as it is to the rest of S2's (H5); - a name word behind the word ends the reach, and so does a - PARTICLE the suffix vocabulary does not also claim: it belongs to - the family the comma already named and is taken there by a rule - that runs after this reading is made (P6), so `Doe, John MA do` - keeps its middle name though the capitals would otherwise have - taken the word. A particle the suffix vocabulary DOES claim is - looked past like any other credential, so `Doe, John MA vd` - reads suffix `MA`, with `vd` attaching behind it. Where the - reach ends, the word is an ordinary middle name, read in silence. - A particle in FRONT of the word takes it out of this slot as - well, and takes the word behind it too: where what follows the - particle reads as a name rather than a credential the two are - one name, so neither word is asked and neither reports — - `Doe, John van Ma` reads middle `van Ma` and `Doe, John DO Ed` - middle `DO Ed`, where `Doe, John van MA` reads suffix `MA` and - `Doe, John DO` alone reads the credential, both reporting. - Every word this slot does read reports the fork whichever way it - went, so a run of members all read as credentials reports once - for each, as the same words do without the comma — while a - member the writing keeps as a name - stops the reading there, and whatever stands in front of it is - name text, asked nothing and reporting nothing. One member of - this class is particle vocabulary as well, and where it stands - alone at this slot P6 decides it: the capitals take it as the - credential, as does an unambiguous credential in front of it (the - company below), and every other spelling attaches to the family, - reported there as P6's fork rather than as this one. Behind - another particle it does not stand alone — the two are one - particle run by then — and the run attaches whatever the capitals - say (P6). - Written case is the other evidence, and it speaks only in a name - written in more than one case: there a member of the ambiguous - set written in capitals reads as the credential even with no - words to spare, and one written in any other cased form that is - not wholly lower reads as the name even with words to spare. A - name written wholly in one case says nothing about 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 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 asked, as the credential run behind one name - word as behind two ('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 trailing slot of the given - part the comma has already settled the count, so the writing is - the only evidence there is. - Company is evidence that outranks both. A member of the ambiguous - set standing BEHIND an unambiguous credential in one run of - suffix words — members of the class between them passing the run - on — reads as the credential whatever its writing and whatever - the count, at every trailing slot this rule names: the degree in - front says what the run is. Only a credential in FRONT speaks; one - behind the member says nothing about it. A connective (P3), a - particle (P2) — a word of both the particle and the suffix - vocabulary heads the family name behind it — and a single-letter - roman numeral, in any case — one letter, the shape a bare middle - initial is written in, where a multi-letter one such as 'III' - speaks — speak for nothing and end the run; so does any name - word. A particle of this class standing behind a credential is - still spoken for: the exclusion is of the word in front. A word - of both the title and the suffix vocabulary standing in the given - part's leading title run is a title there and speaks for - nothing, and no credential behind it speaks for a word of this - class in that part: the title reading makes the next word the - given name, and from there the part reads as any given part does, - the given part's own company included. A part holding no such - word, or only words whose capitals decide them, reads as it did. - Anywhere else such a word speaks like any suffix word. - At the trailing slot of the given part this company outranks P6's - attachment, as the capitals do. It does not reach across a maiden - marker's clause (M2), whose name words stand between. A member - the company decides reports the fork as a counted pick does, - wherever it stands: at the trailing slots above, and in a part - after a family comma that the company leaves holding no name - word, which reads wholly as the credential run. A member its own - capitals already made the credential reports nothing new. - A credential after the name core starts a run to the end of its - part (#602). An unambiguous suffix word of two or more letters, - in any script, standing after two name words where no comma divides - the name or before a suffix comma, or after the given word in - the part after a family comma, makes every later word of that - part a suffix, except a title word, which reads as a title, and - in that given part the particles ending it, behind any - post-nominals, which P6 attaches to the family — other than a - lone member of the ambiguous class, which reads as the - credential. A lone particle is - not a name word for the count, 'de Mesnil' being one surname, - unless the credential stands right behind it, leaving it nothing - to join: then it counts as a name word of its own ('John von PhD - Jones'). A - particle run with the words the chain joins to it is one name - word, 'der la' and 'la Smith Secretary' each counting once (P2). - A title word - never starts the run, and neither does a member of the ambiguous - class, a single letter, a connective, a word that is also - particle or bound given-name vocabulary: a credential is the - writer's mark that the post-nominals have begun, and those carry - no such mark. An honorific or a generational word carries it as - a credential does, whatever its script. A name word the run - absorbs is reported `suffix-or-name`. - An unlisted word joins this same ambiguous class by SHAPE where - the caller asks for it. Two or more period-separated chunks is - one such shape, admitted by default (S3); an unlisted all-caps - alphabetic word of two or more letters, standing in a suffix - 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 - 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 - caller setting the example lines below name, being a surname - there as often as a credential, which only the caller can know; - the cost of that setting is that a three-word name gives up its - 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. - "John Smith Jr." → suffix="Jr." - "Antonio Pavlović mlađi" → family="Pavlović" - "Antonio Pavlović mlađi" → suffix="mlađi" - "Агриппина Младшая" → suffix="Младшая" - "جورج بوش الابن" → suffix="الابن" - "ロバート・ダウニー・ジュニア" → suffix="ジュニア" - "佐藤恵子 国会議員" → suffix="国会議員" - "陳子媚 二世" → suffix="二世" - "Питер Брейгель мл." → suffix="мл." - "Marie Fils" → family="Fils" · boundary - "Jiří Dienstbier ml." → family="ml." · boundary - "Pavlović, Antonio mlađi" → suffix="mlađi" - "Stariji Bruegel" → given="Stariji" · boundary - "John Smith M.A." → suffix="M.A." - "John Smith PhD" → suffix="PhD" - "John Ma" → family="Ma" · boundary - "Jack MA" → suffix="MA" - "Jack Ma" → family="Ma" - "JACK MA" → family="MA" - "JOHN SMITH MA" → suffix="MA" - "John Smith Ma" → family="Ma" - "wang meng" → family="meng" · boundary - "tran lac" → family="lac" - "john smith meng" → suffix="meng" - "john smith MEng" → family="MEng" - "Nguyen Van Lac" → family="Van Lac" - "Wang M.Eng." → suffix="M.Eng." - "John Smith PhD MEng" → suffix="PhD MEng" - "John Smith PhD Ed Ma" → suffix="PhD Ed Ma" - "Wang Ma PhD" → family="Ma" · boundary - "Smith, PhD MEng" → suffix="PhD MEng" - "Smith, Ms Ma" → given="Ma" · boundary - "Smith, PhD Ma" → suffix="PhD Ma" - "Smith, PhD Ma" → ambiguities=("suffix-or-name",) - "Smith, MD PhD Ma" → given="PhD" · boundary - "Doe, Jane PhD MEng" → suffix="PhD MEng" - "doe, jane v phd do" → suffix="v phd do" - "Smith, MA" → suffix="MA" - "Smith, Ma" → given="Ma" - "Doe, John MA" → suffix="MA" - "Doe, John Ma" → middle="Ma" · boundary - "Doe, John MA Smith" → middle="MA Smith" · boundary - "Doe, John DO" → suffix="DO" - "SMITH, JOHN DO" → family="DO SMITH" · boundary - "SMITH, VD MA" → family="SMITH VD MA" - "smith, de ma" → family="smith de ma" - "SMITH, VD DE LA MA" → family="SMITH VD DE LA MA" - "Doe, John MA JD" → ambiguities=("suffix-or-name", "suffix-or-name") - "Doe, John MA Ma" → middle="MA Ma" · boundary - "Doe, John MA Ma" → ambiguities=("suffix-or-name",) · boundary - "Doe, John van Ma" → middle="van Ma" · boundary - "Doe, John van Ma" → ambiguities=() · boundary - "Doe, John DO Ed" → middle="DO Ed" · boundary - "Doe, John DO Ed" → ambiguities=() · boundary - "John Smith XYZ" → family="XYZ" - "John Smith XYZ" unlisted_caps_suffixes-everywhere → suffix="XYZ" - "Jean DUPONT" unlisted_caps_suffixes-everywhere → family="DUPONT" - "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → suffix="DUPONT" - "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → family="Pierre" - "Jack Ma." → family="Ma." · boundary - "Ph. D. Van Johnson" → family="Van Johnson" - "Ph. D. Van Johnson" → title="Ph." - "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." - "II Van Johnson" → given="II" · boundary - "Sir Ph. D. Van Johnson" → suffix="Ph. D." · boundary - "Ph. D., John" → family="Ph. D." · boundary - "John Smith PhD Jones" → suffix="PhD Jones" - "John Smith PhD Jones" → ambiguities=("suffix-or-name",) - "Eric H. Holder Jr. Attorney General" → title="Attorney General" - "John Smith PhD Prof. Ma" → suffix="PhD Ma" - "Smith, John PhD Jones" → suffix="PhD Jones" - "John von PhD Jones" → family="von" - "John von PhD Jones" → suffix="PhD Jones" - "Smith, John PhD de Jr." → family="de Smith" · boundary - "Mary Jane King Smith" → family="Smith" - "Josep Carod i Rovira" → family="Carod i Rovira" - "Mohamed Ali Abd Allah" → family="Allah" - "Antonio Pavlović mlađi Jones" → suffix="mlađi Jones" - "Pieter Bruegel stariji Jones" → suffix="stariji Jones" - "Ана Хорват старији Џонс" → suffix="старији Џонс" - "Гай Плиний Старший Иванов" → suffix="Старший Иванов" - "राम शर्मा साहब मोहन" → suffix="साहब मोहन" - "राम शर्मा साहब मोहन" → ambiguities=("suffix-or-name",) - "John PhD Smith" → family="Smith" · boundary - Accepted: a title before the credential keeps it a credential, - and the name loses its surname exactly as it did before this - clause — `Sir Ph. D. Van Johnson` reads given `Van Johnson` with - an empty family. That is 1.4.0's reading, and the clause cannot - reach it: the first piece of the NAME is not computable before - this decision, since the abbreviation test H2 uses is true of - `Ph.` itself, so a scan that stepped over titles would step over - the very piece being judged. - Accepted: an unambiguous suffix is consumed even when that - leaves no family name at all. - "Smith Jr." → family="" - Accepted: a name-leaning member with nothing in front of it to - speak for it ends the name, words to spare or not, and whatever - stands in front of it is name text. Where a credential does - stand in front, the company above decides instead, except in - one shape that keeps it out of reach and reads the member as the - name: a credential written split across two words with no comma - after the name. That reading is older than the company clause, - so it is witnessed by tests/v2/cases.py's - a_split_degree_in_front_is_out_of_the_walk rather than by a line - here, which would promise a reading this clause did not make. - A title standing between the credential and the member, and a - member a particle chain (P2) had taken, were two more such shapes - until the credential's run (#602) took both in: the rows - a_title_between_degree_and_member_keeps_it_a_name and - a_member_the_particle_chain_took_is_out_of_reach keep their ids - and now pin the run. - "Jack Wei Ma" → family="Ma" - "Jack Wei Ma" → ambiguities=("suffix-or-name",) - "abdul Smith Jr Ma" → suffix="Jr Ma" - Accepted: the title chain no longer takes the word this rule - needs, and the argument a descriptive note here asked for is - made. A title run leaves one NAME word standing and a - post-nominal cannot be it (H3, decided 2026-09-08), so `Dr King - Jr` reads family `King`, suffix `Jr` exactly as this rule - states, where it read title `Dr King`, family `Jr` before — - `Dr Smith Jr`, which always read as stated, was the contrast - that isolated the cause and now reads like its neighbour rather - than against it. What the floor cannot reach stays descriptive - and is small: where the run's whole content is the word given - back, no title is left to name anybody, so the word stands as - the name rather than as the family — `Dr Jr` reads given `Dr`, - suffix `Jr`, reporting `title-or-name` (H4), and `Sir Jr`, the - spelling this note first named, reads by the same branches — - given `Sir`, suffix `Jr`, the same kind reported — the run - being empty by then and no branch reading `vocab:given-title`. - The vocabulary half is decided - 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, H5, C1, C2, S3, P1, P2, P3, P5, P6, M2 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.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 new file mode 100644 index 00000000..8a1817b3 --- /dev/null +++ b/docs/design/rules/S2-suffix-word-ends-a-name-and-never-opens-the-string.md @@ -0,0 +1,90 @@ +# S2 — a suffix word ends a name and never opens the string + +S2. Rationale: generational suffixes and credentials are recognized + by vocabulary; an acronym that is also an ordinary name is only + unmistakably a credential when its periods are written. + A suffix never OPENS THE STRING: position outranks the + vocabulary match, so a suffix-shaped word written before anything + else reads as whatever its position and shape make it — a title + where it wears the abbreviation shape (H2), an ordinary name word + otherwise — and never as a credential belonging to a name that + has not been written yet. The string, not the name: a title, a + nickname or a bracketed clause standing first leaves the + credential reading intact, which is the ONE place this document + 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 + title run opening it (H6). + A trailing word of the suffix vocabulary reads as a suffix — + generational forms and credential acronyms alike, and an + ambiguous acronym written with its periods, one after each + letter or one after each of two or more letter chunks, counts + unambiguously; a single trailing period is the abbreviation + shape any word can wear and does not. The run at a name's + trailing slot is read ONCE, once the connectives have joined (P3) + and before the particle chain (P2) and the bound given-name join + (P5) are made, counting a particle run and the name words the + chain will take with it as the one name word it will make of + them — the words both particles and suffix vocabulary standing + straight behind a particle among them, while a word the reading + weighs counts as a word of its own wherever it stands; inside a + credential run a word stands on its own, a title word there still + a title — and neither join reaches a word the run took. A bare + ambiguous acronym in that run is read by S4, S5, S6 and S7, and a + credential with more name text behind it by S8. + "John Smith Jr." → suffix="Jr." + "Antonio Pavlović mlađi" → family="Pavlović" + "Antonio Pavlović mlađi" → suffix="mlađi" + "Агриппина Младшая" → suffix="Младшая" + "جورج بوش الابن" → suffix="الابن" + "ロバート・ダウニー・ジュニア" → suffix="ジュニア" + "佐藤恵子 国会議員" → suffix="国会議員" + "陳子媚 二世" → suffix="二世" + "Питер Брейгель мл." → suffix="мл." + "Marie Fils" → family="Fils" · boundary + "Jiří Dienstbier ml." → family="ml." · boundary + "Pavlović, Antonio mlađi" → suffix="mlađi" + "Stariji Bruegel" → given="Stariji" · boundary + "John Smith M.A." → suffix="M.A." + "Wang M.Eng." → suffix="M.Eng." + "John Smith PhD" → suffix="PhD" + "Jack Ma." → family="Ma." · boundary + "Ph. D. Van Johnson" → family="Van Johnson" + "Ph. D. Van Johnson" → title="Ph." + "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." + "II Van Johnson" → given="II" · boundary + "Sir Ph. D. Van Johnson" → suffix="Ph. D." · boundary + "Ph. D., John" → family="Ph. D." · boundary + Accepted: a title before the credential keeps it a credential, + and the name loses its surname exactly as it did before this + clause — `Sir Ph. D. Van Johnson` reads given `Van Johnson` with + an empty family. That is 1.4.0's reading, and the clause cannot + reach it: the first piece of the NAME is not computable before + this decision, since the abbreviation test H2 uses is true of + `Ph.` itself, so a scan that stepped over titles would step over + the very piece being judged. + Accepted: an unambiguous suffix is consumed even when that + leaves no family name at all. + "Smith Jr." → family="" + Accepted: the title chain no longer takes the word this rule + needs, and the argument a descriptive note here asked for is + made. A title run leaves one NAME word standing and a + post-nominal cannot be it (H3, decided 2026-09-08), so `Dr King + Jr` reads family `King`, suffix `Jr` exactly as this rule + states, where it read title `Dr King`, family `Jr` before — + `Dr Smith Jr`, which always read as stated, was the contrast + that isolated the cause and now reads like its neighbour rather + than against it. What the floor cannot reach stays descriptive + and is small: where the run's whole content is the word given + back, no title is left to name anybody, so the word stands as + the name rather than as the family — `Dr Jr` reads given `Dr`, + suffix `Jr`, reporting `title-or-name` (H4), and `Sir Jr`, the + spelling this note first named, reads by the same branches — + given `Sir`, suffix `Jr`, the same kind reported — the run + being empty by then and no branch reading `vocab:given-title`. + The vocabulary half is decided + 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, P2, P3, P4, P5 · implemented: nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.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 9fc069a2..7c08df8f 100644 --- a/docs/design/rules/S3-credentials-written-with-interior-periods.md +++ b/docs/design/rules/S3-credentials-written-with-interior-periods.md @@ -11,9 +11,9 @@ S3. Rationale: credentials are often written run together with which are about generations rather than credentials. A word of two or more period-separated chunks that no vocabulary claims is read by POSITION instead, as a bare - ambiguous acronym is (S2): a credential where the name has words + ambiguous acronym is (S4): a credential where the name has words to spare, a name word where it does not, either reading - reported at the slots S2 reports at, and the same at a comma — + reported at the slots S4 reports at, and the same at a comma — which means the FIRST piece after a family comma, the word trailing the given part after one, the word ending a maiden marker's clause (M2), and the part before a SUFFIX @@ -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/S2 · interacts: S2, C1, H2, W3 · implemented: nameparser/_pipeline/_vocab.py + history: decisions/S2 · interacts: C1, 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 new file mode 100644 index 00000000..32662437 --- /dev/null +++ b/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md @@ -0,0 +1,30 @@ +# S4 — the ambiguous acronym needs words to spare + +S4. Rationale: an acronym that is also an ordinary name (MA, Ma) + can be given up to the credential reading only where the name + keeps a word for its family; a name of two words has none to + spare. + A BARE ambiguous acronym is consumed only + when the name has words to spare — as the second of two words it + stays the family name — and at the slots that report, either + reading carries the ambiguity flag. Those slots are the trailing + slot of a name, the first slot after a family comma, the + trailing slot of the GIVEN part after that comma, and the + trailing slot of a maiden marker's clause (M2). A part beyond + the second comma is not one of them; what such a part does + report is C2's to say. A word this document says is READ at one + of those slots is not always a word one of them reports: where a + reading moves a word out of the slot that asked about it, what + reports is the slot it lands in, and that may be none — M2 + states the case where a clause does it. The count is one piece + 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 (S6), and an unambiguous credential in + front outranks both (S7). + "John Ma" → family="Ma" · boundary + "wang meng" → family="meng" · boundary + "tran lac" → family="lac" + "john smith meng" → suffix="meng" + "JOHN SMITH MA" → suffix="MA" + "Nguyen Van Lac" → family="Van Lac" + history: decisions/S2 · interacts: C1, C2, M2, S2, S3, S5, S6, S7, S9 · implemented: nameparser/_pipeline/_group.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 new file mode 100644 index 00000000..c3cc0aed --- /dev/null +++ b/docs/design/rules/S5-given-parts-trailing-slot-after-a-family-comma.md @@ -0,0 +1,69 @@ +# S5 — the given part's trailing slot after a family comma + +S5. Rationale: a family comma has already named the family and the + given name, so at the end of the given part the words-to-spare + count has nothing to say, and only the writing can. + After a family comma, a member of the ambiguous class (S4) + ending the GIVEN part is read as the comma-less spelling reads a + word ending the name, and the count is not what decides it + there. The comma has already named the family and the first + name word after it is the given name, so the words to spare are + there by construction and the count says nothing — unless that + first word is a never-given particle, which is no given name and + needs the next name word to attach to (P1): that word is the + surname the particle heads, not a word to spare, so it reads as + a name unless its capitals in a mixed-case name say credential, + as they do with no words to spare anywhere (S6): `SMITH, VD MA` + reads as `Smith, vd Ma` does, `Smith, de MA` keeps suffix `MA`, + and `SMITH, VAN MA`, whose particle can be a given name, keeps + it and reads suffix `MA`. Otherwise a word that ENDS that part + reads as the credential unless its WRITING says otherwise, and a + word that does not end it is never asked. "Ending the given + part" reaches past the credentials behind it and past a trailing + title, which is transparent to this reading as it is to the rest + of the trailing reading (H5); a name word behind the word ends + the reach, and so does a PARTICLE the suffix vocabulary does not + also claim: it belongs to the family the comma already named and + is taken there by a rule that runs after this reading is made + (P6), so `Doe, John MA do` keeps its middle name though the + capitals would otherwise have taken the word. A particle the + suffix vocabulary DOES claim is looked past like any other + credential, so `Doe, John MA vd` reads suffix `MA`, with `vd` + attaching behind it. Where the reach ends, the word is an + ordinary middle name, read in silence. + A particle in FRONT of the word takes it out of this slot as + well, and takes the word behind it too: where what follows the + particle reads as a name rather than a credential the two are + one name, so neither word is asked and neither reports — + `Doe, John van Ma` reads middle `van Ma` and `Doe, John DO Ed` + middle `DO Ed`, where `Doe, John van MA` reads suffix `MA` and + `Doe, John DO` alone reads the credential, both reporting. + Every word this slot does read reports the fork whichever way it + went, so a run of members all read as credentials reports once + for each, as the same words do without the comma — while a + member the writing keeps as a name stops the reading there, and + whatever stands in front of it is name text, asked nothing and + reporting nothing. One member of the class is particle + vocabulary as well, and where it stands alone at this slot P6 + decides it: the capitals take it as the credential, as does an + unambiguous credential in front of it (S7), and every other + spelling attaches to the family, reported there as P6's fork + rather than as this one. Behind another particle it does not + stand alone — the two are one particle run by then — and the + run attaches whatever the capitals say (P6). + "Doe, John MA" → suffix="MA" + "Doe, John Ma" → middle="Ma" · boundary + "Doe, John MA Smith" → middle="MA Smith" · boundary + "Doe, John DO" → suffix="DO" + "SMITH, JOHN DO" → family="DO SMITH" · boundary + "SMITH, VD MA" → family="SMITH VD MA" + "smith, de ma" → family="smith de ma" + "SMITH, VD DE LA MA" → family="SMITH VD DE LA MA" + "Doe, John MA JD" → ambiguities=("suffix-or-name", "suffix-or-name") + "Doe, John MA Ma" → middle="MA Ma" · boundary + "Doe, John MA Ma" → ambiguities=("suffix-or-name",) · boundary + "Doe, John van Ma" → middle="van Ma" · boundary + "Doe, John van Ma" → ambiguities=() · boundary + "Doe, John DO Ed" → middle="DO Ed" · boundary + "Doe, John DO Ed" → ambiguities=() · boundary + history: decisions/S2 · interacts: C1, 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 new file mode 100644 index 00000000..a3ab6475 --- /dev/null +++ b/docs/design/rules/S6-case-signal-at-the-suffix-slot.md @@ -0,0 +1,35 @@ +# S6 — the case signal at the suffix slot + +S6. Rationale: in a name written in more than one case, the + writer's capitals are evidence the count does not have: + credentials are written in capitals and names are not. A name + written in one case carries no such evidence. + Written case is evidence beside the count (S4), and it speaks + only in a name written in more than one case: there a member of + the ambiguous set written in capitals reads as the credential + even with no words to spare, and one written in any other cased + form that is not wholly lower reads as the name even with words + to spare. A name written wholly in one case says nothing about + 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 + 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 + asked, as the credential run behind one name word as behind two + ('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 + 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" + "Jack Ma" → family="Ma" + "JACK MA" → family="MA" · boundary + "John Smith Ma" → family="Ma" + "john smith MEng" → family="MEng" + "Smith, MA" → suffix="MA" + "Smith, Ma" → given="Ma" + history: decisions/S2 · interacts: C1, 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 new file mode 100644 index 00000000..25cfc42f --- /dev/null +++ b/docs/design/rules/S7-company-of-an-unambiguous-credential.md @@ -0,0 +1,67 @@ +# S7 — the company of an unambiguous credential + +S7. Rationale: post-nominals are written as a run, and an + unambiguous degree in front of an ambiguous member says what the + run is. + Company is evidence that outranks both the count (S4) and the + case (S6). A member of the ambiguous set standing BEHIND an + unambiguous credential in one run of suffix words — members of + the class between them passing the run on — reads as the + credential whatever its writing and whatever the count, at every + trailing slot S4 names: the degree in front says what the run + is. Only a credential in FRONT speaks; one behind the member + says nothing about it. A connective (P3), a particle (P2) — a + word of both the particle and the suffix vocabulary heads the + family name behind it — and a single-letter roman numeral, in + any case — one letter, the shape a bare middle initial is + written in, where a multi-letter one such as 'III' speaks — + speak for nothing and end the run; so does any name word. A + particle of this class standing behind a credential is still + spoken for: the exclusion is of the word in front. A word of + both the title and the suffix vocabulary standing in the given + part's leading title run is a title there and speaks for + nothing, and no credential behind it speaks for a word of this + class in that part: the title reading makes the next word the + given name, and from there the part reads as any given part + does, the given part's own company included. A part holding no + such word, or only words whose capitals decide them, reads as it + did. Anywhere else such a word speaks like any suffix word. + At the trailing slot of the given part this company outranks + P6's attachment, as the capitals do. It does not reach across a + maiden marker's clause (M2), whose name words stand between. A + member the company decides reports the fork as a counted pick + does, wherever it stands: at the trailing slots S4 names, and in + a part after a family comma that the company leaves holding no + name word, which reads wholly as the credential run. A member + its own capitals already made the credential reports nothing + new. + "John Smith PhD MEng" → suffix="PhD MEng" + "John Smith PhD Ed Ma" → suffix="PhD Ed Ma" + "Wang Ma PhD" → family="Ma" · boundary + "Smith, PhD MEng" → suffix="PhD MEng" + "Smith, Ms Ma" → given="Ma" · boundary + "Smith, PhD Ma" → suffix="PhD Ma" + "Smith, PhD Ma" → ambiguities=("suffix-or-name",) + "Smith, MD PhD Ma" → given="PhD" · boundary + "Doe, Jane PhD MEng" → suffix="PhD MEng" + "doe, jane v phd do" → suffix="v phd do" + Accepted: a name-leaning member with nothing in front of it to + speak for it ends the name, words to spare or not, and whatever + stands in front of it is name text. Where a credential does + stand in front, the company above decides instead, except in + one shape that keeps it out of reach and reads the member as the + name: a credential written split across two words with no comma + after the name. That reading is older than the company clause, + so it is witnessed by tests/v2/cases.py's + a_split_degree_in_front_is_out_of_the_walk rather than by a line + here, which would promise a reading this clause did not make. + A title standing between the credential and the member, and a + member a particle chain (P2) had taken, were two more such shapes + until the credential's run (S8) took both in: the rows + a_title_between_degree_and_member_keeps_it_a_name and + a_member_the_particle_chain_took_is_out_of_reach keep their ids + and now pin the run. + "Jack Wei Ma" → family="Ma" + "Jack Wei Ma" → ambiguities=("suffix-or-name",) + "abdul Smith Jr Ma" → suffix="Jr Ma" + history: decisions/S2 · interacts: C1, 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 new file mode 100644 index 00000000..4cc60428 --- /dev/null +++ b/docs/design/rules/S8-credentials-trailing-run.md @@ -0,0 +1,48 @@ +# S8 — the credential's trailing run + +S8. Rationale: a credential is the writer's mark that the + post-nominals have begun, so what follows it in its part is + post-nominal too. + A credential after the name core starts a run to the end of its + part (#602). An unambiguous suffix word of two or more letters, + in any script, standing after two name words where no comma divides + the name or before a suffix comma, or after the given word in + the part after a family comma, makes every later word of that + part a suffix, except a title word, which reads as a title, and + in that given part the particles ending it, behind any + post-nominals, which P6 attaches to the family — other than a + lone member of the ambiguous class, which reads as the + credential. A lone particle is + not a name word for the count, 'de Mesnil' being one surname, + unless the credential stands right behind it, leaving it nothing + to join: then it counts as a name word of its own ('John von PhD + Jones'). A + particle run with the words the chain joins to it is one name + word, 'der la' and 'la Smith Secretary' each counting once (P2). + A title word + never starts the run, and neither does a member of the ambiguous + class, a single letter, a connective, a word that is also + particle or bound given-name vocabulary: a credential is the + writer's mark that the post-nominals have begun, and those carry + no such mark. An honorific or a generational word carries it as + a credential does, whatever its script. A name word the run + absorbs is reported `suffix-or-name`. + "John Smith PhD Jones" → suffix="PhD Jones" + "John Smith PhD Jones" → ambiguities=("suffix-or-name",) + "Eric H. Holder Jr. Attorney General" → title="Attorney General" + "John Smith PhD Prof. Ma" → suffix="PhD Ma" + "Smith, John PhD Jones" → suffix="PhD Jones" + "John von PhD Jones" → family="von" + "John von PhD Jones" → suffix="PhD Jones" + "Smith, John PhD de Jr." → family="de Smith" · boundary + "Mary Jane King Smith" → family="Smith" + "Josep Carod i Rovira" → family="Carod i Rovira" + "Mohamed Ali Abd Allah" → family="Allah" + "Antonio Pavlović mlađi Jones" → suffix="mlađi Jones" + "Pieter Bruegel stariji Jones" → suffix="stariji Jones" + "Ана Хорват старији Џонс" → suffix="старији Џонс" + "Гай Плиний Старший Иванов" → suffix="Старший Иванов" + "राम शर्मा साहब मोहन" → suffix="साहब मोहन" + "राम शर्मा साहब मोहन" → ambiguities=("suffix-or-name",) + "John PhD Smith" → family="Smith" · boundary + history: decisions/S2 · interacts: H5, M2, P2, P3, P5, P6, S2, S4, S7, C1 · 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 new file mode 100644 index 00000000..7a1eed3c --- /dev/null +++ b/docs/design/rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -0,0 +1,34 @@ +# S9 — unlisted words that join the ambiguous class by shape + +S9. Rationale: no wordlist holds every credential, and a word's + shape can be written the way a credential is written; but a + shape is also how some names are written, so it buys the + ambiguous reading, never the unambiguous one. + An unlisted word joins the ambiguous class (S4) by SHAPE where + the caller asks for it. Two or more period-separated chunks is + one such shape, admitted by default (S3); an unlisted all-caps + alphabetic word of two or more letters, standing in a suffix + 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 + 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 + caller setting the example lines below name, being a surname + there as often as a credential, which only the caller can know; + the cost of that setting is that a three-word name gives up its + 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. + "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/S2 · interacts: C1, S3, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py diff --git a/docs/design/rules/W3-writers-divisions-are-respected.md b/docs/design/rules/W3-writers-divisions-are-respected.md index 79cdf26a..f328526e 100644 --- a/docs/design/rules/W3-writers-divisions-are-respected.md +++ b/docs/design/rules/W3-writers-divisions-are-respected.md @@ -35,7 +35,7 @@ W3. Rationale: a family name declared by a comma is the writer's The vocabulary question is C1's own, asked without C1's word-count condition: the two differ in what else they require, not in what they ask of the words — the written-case evidence - S2 reads included, so a post-comma acronym the case leans + S6 reads included, so a post-comma acronym the case leans credential declines the side it stands on here exactly as it reads as the credential there, and the honorific comes off the part before the comma as it does behind a settled credential. @@ -68,5 +68,5 @@ W3. Rationale: a family name declared by a comma is the writer's "김민준씨." → suffix="씨." "田中. 太郎" → family="田中." tolerated: native CJK writing has neither a family-comma convention nor an edge full stop of any width on a name word, so the five comma lines above and the three period lines under them illustrate current behavior — changeable without notice — rather than promise it; the line carrying neither, beside them, is W1's claim, which is normative. All eight stay watched by the snapshot (tools/differential/snapshot.jsonl, over corpus_rules.jsonl and corpus_cjk.jsonl), and the `tolerated` rows of tests/v2/cases.py pin them at HEAD. - history: decisions/W3 · interacts: W1, W2, C1, H2, H6 · implemented: nameparser/_pipeline/_script_segment.py + history: decisions/W3 · interacts: W1, W2, C1, H2, H6, S6 · implemented: nameparser/_pipeline/_script_segment.py diff --git a/nameparser/_lexicon.py b/nameparser/_lexicon.py index c3bdcb45..06acc453 100644 --- a/nameparser/_lexicon.py +++ b/nameparser/_lexicon.py @@ -599,7 +599,7 @@ class Lexicon: #: outright; written bare, capitals decide in a mixed-case name #: ("Jack MA" is the credential, "Jack Ma" keeps his family name) #: and otherwise the word counts as a suffix only where a name - #: word is left beside it ("John Smith ma"), rules/S2. Full + #: word is left beside it ("John Smith ma"), rules/S6 and rules/S4. Full #: default list: #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS_AMBIGUOUS`. suffix_acronyms_ambiguous: frozenset[str] = frozenset() diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index b3f30696..377dec4a 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -112,7 +112,7 @@ def _set_roles(tokens: list[WorkToken], piece: tuple[int, ...], def _absorbed(piece: Sequence[int], tokens: Sequence[WorkToken]) -> PendingAmbiguity: - """rules/S2's report for a name word #602's run absorbs, one + """rules/S8's report for a name word #602's run absorbs, one spelling for the no-comma and the given-part run.""" text = " ".join(tokens[i].text for i in piece) return PendingAmbiguity( @@ -397,7 +397,7 @@ def _assign_main(seg_idx: int, state: ParseState, state.one_case) for piece_idx in titled_tail: _set_roles(tokens, pieces[piece_idx], Role.TITLE) - # rules/S2: "except a title word, which reads as a title" -- the + # rules/S8: "except a title word, which reads as a title" -- the # credential run (#602) is placed here, so a name word it absorbed # is reported here too for piece_idx in peeled.run_titles: @@ -531,7 +531,8 @@ def _assign_main(seg_idx: int, state: ParseState, assert token.role is not None declined = ("a name part" if token.role is Role.SUFFIX else "a post-nominal") - # A word in the class by SHAPE (rules/S2, #S3) is in no + # rules/S9: "An unlisted word joins the ambiguous class (S4) by + # SHAPE" (and rules/S3); such a word is in no # wordlist and may be written with its periods, so the listed # member's wording would misdescribe it on both counts (#563) what = ("is shaped like a post-nominal but listed in no " @@ -612,7 +613,7 @@ def _inside_a_credential_run(seg: Sequence[Sequence[int]], JANE PHD VD MA' does. (Behind it no title is skipped: a title there leaves the word a name in assign, so nothing reaches here.) - The test is the ROLES assign gave, not S2's company query: the + The test is the ROLES assign gave, not S7's company query: the question is whether assign read the run whole, and a member in front that the writing or a count made the credential ('DOE, JANE MA VD PHD') says so as plainly as a degree does. So does a @@ -622,7 +623,7 @@ def _inside_a_credential_run(seg: Sequence[Sequence[int]], role is what keeps a plain particle out ('Doe, Jane PhD de PhD', whose 'de' is a name word, not a post-nominal), and the given name in front is what keeps 'Doe, Jane vd PhD' attaching. - Reported as S2's credential fork, at the site that declines the + Reported as S4's credential fork, at the site that declines the attachment.""" def suffix_read(q: int) -> bool: return all(tokens[i].role is Role.SUFFIX for i in seg[q]) @@ -848,7 +849,7 @@ def assign(state: ParseState) -> ParseState: # keeps as a name stops that run, which is why # 'Doe, John MA Ma' reports once and for 'Ma' alone -- 'MA' # then has a name word behind it and is never asked - # (rules/S2). + # (rules/S5). if state.pieces[1] and len(state.pieces[1][0]) == 1: i = state.pieces[1][0][0] if not tokens[i].tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): @@ -957,7 +958,7 @@ def trailing_floor(m: int, titled: frozenset[int]) -> int: down as `m` needs it: `m >= trailing_floor(m, titled)` is exactly "every kept piece behind `m` reads as a suffix", which is what ENDING the given part means - (rules/S2, #531). + (rules/S5, #531). ONE walk per `titled` value, shared by the predicate below and by that rule's report at the foot of this @@ -1030,7 +1031,7 @@ def attaches_the_lead(m: int, titled: frozenset[int]) -> bool: member's writing says nothing: capitals in a mixed-case name still make it the credential ('Smith, de MA' keeps suffix 'MA'), as they do with no words to spare - anywhere (rules/S2). Asked only of a class member + anywhere (rules/S5). Asked only of a class member the slot would otherwise take.""" if not is_lone_never_given_particle(pieces[n], tokens): return False @@ -1077,7 +1078,9 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: """ if is_suffix_piece(pieces[m], ptags[m], tokens): return True - # rules/S2, the given part's trailing slot (#531). + # rules/S5: "a word that ENDS that part reads as the credential + # unless its WRITING says otherwise" -- the given part's + # trailing slot (#531). # INLINE, and that is the frame budget talking rather # than taste: the candidates pass asks this closure once # per piece of every family-comma segment, so a helper @@ -1102,7 +1105,7 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: # # The comma has already named the family and the first # piece after it is the given name, so the words to - # spare S2's count asks about are there by + # spare S4's count asks about are there by # construction and the count says nothing at this # slot. What is left is the writing, which is the same # evidence the comma-less spelling of the same name @@ -1248,7 +1251,7 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: # memo, not a second spelling of the question, and it # cannot have gone stale because nothing the chain does # moved the end of the name. - # rules/S2: "or after the given word in the part after a + # rules/S8: "or after the given word in the part after a # family comma" -- the given part's own credential run # (#602), the comma having fixed the family. The tag tests are inline ahead of the # predicate, as in `_pieces.credential_run`, so a name word diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 6e117797..1b74b16a 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -39,8 +39,8 @@ suffix vocabulary, or an ambiguous acronym written with periods -- at the TAG level 'M.A.' gets "vocab:suffix" while 'Ma' gets only "vocab:suffix-ambiguous"; what assign then does with a trailing -ambiguous tag is the rest of rule S2's statement (the -words-to-spare guard) and its Accepted consequences. +ambiguous tag is rules S4 to S7 (the words-to-spare guard, the +case, the company) and their Accepted consequences. The initial veto is assign's job, not classify's: 'V' carries both "vocab:suffix" and "initial". "shape:acronym" is the one tag in the shape: namespace and it records @@ -178,7 +178,8 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # title as a whole ('Lt.Gov.', and by the ANY rule 'Mr.Smith'); # else ANY suffix chunk makes it a suffix ('JD.CPA'). Title wins # (v1's continue). The third and fourth are 2.4's by-shape halves - # (#516) and reach only a token NO vocabulary claimed: a dotted + # (#516; rules/S9: "An unlisted word joins the ambiguous class + # (S4) by SHAPE where the caller asks for it") and reach only a token NO vocabulary claimed: a dotted # token of two or more alphabetic chunks, and -- where its switch # is on -- an unlisted all-caps word. They are an `elif` chain # with the dotted one first, so a dotted token never reaches the diff --git a/nameparser/_pipeline/_comma.py b/nameparser/_pipeline/_comma.py index 8771e49c..cd72e904 100644 --- a/nameparser/_pipeline/_comma.py +++ b/nameparser/_pipeline/_comma.py @@ -406,7 +406,8 @@ def decide(state: ParseState) -> ParseState: AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i].text!r} after the comma is also an ordinary name " f"word; read as a credential", (i,))) - # rules/S2's company, reported where it decided: a member the + # rules/S7: "A member the company decides reports the fork as a + # counted pick does" -- reported where it decided: a member the # anchor read as a credential after its own writing declined (#544) for k in anchored: i = p1[k][0] diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index 8a6178a7..e20428c8 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -257,7 +257,7 @@ def _maiden_take(pieces: Sequence[Sequence[int]], name reads as if the clause were not written, and always the first word after it. The run is read over the VIEW -- the head plus the vocabulary-shaped tail, and anything from the first - credential that starts a run (S2, #602) -- by `tail_reading`, so + credential that starts a run (S8, #602) -- by `tail_reading`, so the count of words to spare is the clause-free name's. Before a family comma no trailing rule reads the part, and the family part takes a suffix word from anywhere in it: the clause stops @@ -1029,7 +1029,7 @@ def chain(tail: int) -> None: f"following name piece; it is also a given " f"name in other names", (i,))) - # rules/S2: "A BARE ambiguous acronym is consumed + # rules/S4: "A BARE ambiguous acronym is consumed # only when the name has words to spare — as the second # of two words it stays the family name — and at the # slots that report, either reading carries the diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index ddaca7b8..f07d1b0c 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -460,7 +460,7 @@ def is_suffix_piece(piece: Sequence[int], ptags: Set[str], return "vocab:suffix" in tags and "initial" not in tags -# rules/S2: "A title word never starts the run, and neither does a +# rules/S8: "A title word never starts the run, and neither does a # member of the ambiguous class, a single letter, a connective" -- what # starts #602's credential run, one predicate for the trailing peel and # the given-part walk (mechanisms.md#ONE-PREDICATE-PER-QUESTION) @@ -593,8 +593,9 @@ def _anchors(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: and not is_single_letter_numeral(tok.text)) -# #544: an ambiguous member standing BEHIND an unambiguous credential -# in one run is described by that credential's company, not by its +# rules/S7: "Only a credential in FRONT speaks; one behind the +# member says nothing about it." #544: an ambiguous member standing +# BEHIND an unambiguous credential in one run is described by that credential's company, not by its # own writing -- 'PhD MEng' is a list of degrees whatever case 'MEng' # is written in. ONE forward pass per question, so a run of members # is read in linear time: a look-behind per member is quadratic in @@ -866,7 +867,7 @@ def segment_suffix_reading(pieces: Sequence[Sequence[int]], # A word rules/C1'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 S2's anchors are asked (#613) + # ambiguous word here before S7's anchors are asked (#613) if len(piece) == 1 and piece[0] in licensed: leading = False out.append(True) @@ -1098,7 +1099,7 @@ def peel_trailing(rest: Sequence[int], pieces: Sequence[Sequence[int]], `one_case` is ParseState.one_case, the recorded fact: None means nobody asked, which is every caller that has no state to ask with, - and reads as "no lean" -- rules/S2's count alone, the behavior + and reads as "no lean" -- rules/S4's count alone, the behavior of every release before this one. `start` and `anchors` resume a walk rather than begin one: the @@ -1223,7 +1224,7 @@ def peel_trailing(rest: Sequence[int], pieces: Sequence[Sequence[int]], return Peel(k, numeral, tuple(picks), anchors) -# rules/S2: "A credential after the name core starts a run to the +# rules/S8: "A credential after the name core starts a run to the # end of its part" -- applied to a finished peel (#602), so every # reader of the trailing run (assign, P5's reserve, P2's chain stop, # the maiden take) sees one answer. @@ -1572,7 +1573,7 @@ def tail_reading(rest: list[int], pieces: Sequence[Sequence[int]], segment, the one read group takes between P3's joins and the particle chain (`read_trailing_run`), which assign and the reserve take their answer from instead. Deriving that agreement twice is what left the two - disagreeing at S2's bare-ambiguous reserve: 'abdul rahman MA' + disagreeing at S4's bare-ambiguous reserve: 'abdul rahman MA' declined the join and 'abdul rahman MA Prof.' took it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index a04f016b..e399cdf4 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -357,14 +357,15 @@ def is_one_case(texts: Sequence[str]) -> bool: Lean = Literal["credential", "name"] -# #289's credential lean: rules/S2 records the words-to-spare count -# today; this predicate is the WRITING evidence that decides ahead of -# it, in a mixed-case name only (history: decisions/S2). +# #289's credential lean, rules/S6: "Written case is evidence beside +# the count (S4), and it speaks only in a name written in more than +# one case" -- the WRITING evidence that decides ahead of the +# words-to-spare count (history: decisions/S2). def ambiguous_lean(text: str, one_case: bool) -> Lean | None: """Which way the WRITING leans for a member of the ambiguous credential set: "credential", "name", or None for no lean at all. - None is the fall-through to rules/S2's count, and it is the + None is the fall-through to rules/S4's count, and it is the answer for three inputs. A name written wholly in one case says nothing about any word in it. A member written wholly in lower case says nothing either -- lower is how most of a mixed-case diff --git a/nameparser/_types.py b/nameparser/_types.py index cb046cf3..5a8d3cf0 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -436,7 +436,7 @@ class AmbiguityKind(StrEnum): #: family name remains; "JACK MA", written wholly in one case, keeps #: it as the name because none would, while in a mixed-case name #: the capitals decide outright -- "Jack MA" is the credential and - #: "Jack Ma" the name, rules/S2) and a trailing roman numeral, which is a suffix where any + #: "Jack Ma" the name, rules/S6) and a trailing roman numeral, which is a suffix where any #: other single letter would be a name ("John Smith V" vs "John #: Smith B"). It also covers a family-comma listing, where a #: trailing abbreviation that is both a post-nominal and a surname @@ -467,7 +467,7 @@ class AmbiguityKind(StrEnum): #: Since #544 C1'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/S2) is a pick like a counted one, + #: it speaks for (rules/S7) is a pick like a counted one, #: reported where it was decided: by the peel, by the given part's #: trailing slot, or by the reading of a part after a family comma #: that the company leaves holding no name word ("John Smith PhD diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 598731c6..bf4a38e3 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -327,7 +327,7 @@ SUFFIX_ACRONYMS_AMBIGUOUS = frozenset({ # Suffix acronyms that also commonly work as given-name nicknames on # their own (e.g. "Ed", "JD"). Read wherever the pipeline decides an - # acronym's bare reading (rules/S2) and by the delimited-content + # acronym's bare reading (rules/S4) and by the delimited-content # escape (content matching one of these stays a nickname, the more # common reading in ambiguous, delimiter-only context). _classify # also tags membership as "vocab:suffix-ambiguous". @@ -342,7 +342,7 @@ # written in capitals inside a mixed-case name; inside a # mixed-case name any other cased form that is not wholly lower # ('Ma', 'MEng') reads as the name even with words to spare -- - # rules/S2. The parse reports the fork where it decides one, + # rules/S6. The parse reports the fork where it decides one, # so 'Jack Ma' keeps its family name. # # The other half of the criterion, added 2026-09-07 with #342. @@ -418,7 +418,7 @@ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a word borne as a name -- a given name, nickname or surname. Not a partition of SUFFIX_ACRONYMS -- a small, standalone exception list, read -wherever the pipeline decides an acronym's bare reading (rules/S2). +wherever the pipeline decides an acronym's bare reading (rules/S4). """ SUFFIX_ACRONYMS = frozenset({ diff --git a/tests/v2/cases.py b/tests/v2/cases.py index eb8b2db0..6b45c646 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -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, S2), so the comma is a suffix " + "default (rules/C1, 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"), @@ -567,14 +567,14 @@ def _check_cjk_shape_purity(self) -> None: # common credential and Ba a real surname (Vietnamese; Senegalese # Fula), which is the ma/Ma shape exactly, so it takes the # marking rather than the removal. Two rows, the two halves of - # S2's words-to-spare guard; the release note names both texts. + # S4's words-to-spare guard; the release note names both texts. Case("bare_ba_is_a_surname", "Anna Ba", {"given": "Anna", "family": "Ba"}, ambiguities=("suffix-or-name",), classification="parity", notes="the marking's point: with only two pieces, 'one of " "them is a credential' is the less likely reading, so " - "S2's words-to-spare guard keeps the family name and " + "S4's words-to-spare guard keeps the family name and " "the parse reports which reading it took. 2.0.0 " "through 2.2.0 read suffix 'Ba' with no family name; " "1.4.0 read family 'Ba' unflagged, so this row " @@ -590,7 +590,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the marking's comma cost, and it is 'Smith, Ed' " - "arriving for a second word: with 'ba' ambiguous, S2 " + "arriving for a second word: with 'ba' ambiguous, S4 " "declines the post-comma credential and C1 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 " @@ -610,7 +610,7 @@ def _check_cjk_shape_purity(self) -> None: # Lac a Vietnamese given name (the trailing word in native order, # Nguyen Van Lac) and a French surname, and both stand in the one # slot the suffix vocabulary claims, the last word of a name. No rule - # moves: these rows are rules/S2's forks read through two more + # moves: these rows are rules/S4's forks read through two more # members, written all-lower so the count decides unless the # writing says otherwise, and shape-tagged so the snapshot sees # them -- no corpus name had either word. @@ -618,7 +618,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "wang", "family": "meng"}, ambiguities=("suffix-or-name",), notes="#540's subject: with only two pieces the credential " - "reading would leave no family name, so S2's " + "reading would leave no family name, so S4's " "words-to-spare guard keeps it and reports the fork. " "2.0.0 through 2.3.0 read suffix 'meng' with no family " "(2.3.0 reporting given-or-family, the one-word-name " @@ -664,11 +664,11 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the marking's accepted cost, stated rather than " "discovered: in a name written in more than one case, " - "S2 reads a member written neither in capitals nor all " + "S6 reads a member written neither in capitals nor all " "lower as the name even with words to spare, and MEng " "is conventionally written exactly that way. Every " "release from 1.4.0 read suffix 'MEng'. The twin of " - "S2's 'John Smith Ma'; 'John Smith MENG' keeps the " + "S6's 'John Smith Ma'; 'John Smith MENG' keeps the " "credential on the capitals lean", shape=1), Case("comma_mixed_case_meng_reads_as_a_middle_name", @@ -676,7 +676,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "middle": "MEng", "family": "Smith"}, classification="fix(#540)", ambiguities=("suffix-or-name",), - notes="the marking's comma cost: it is the same S2 case " + 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 " "and the writing is the only evidence left. Every " @@ -688,7 +688,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the marking's one-word-before-the-comma path, the " "reading 'Smith, Ma' gets, arriving for another " - "member: with the word ambiguous, S2 declines the " + "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 " "2.3.0 read " @@ -737,7 +737,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#540)", ambiguities=("suffix-or-name",), notes="the mixed-case name's Title-case member leans " - "'name' with words to spare (S2), the gain for a " + "'name' with words to spare (S6), the gain for a " "Vietnamese name; every release from 1.4.0 read " "family 'Van', suffix 'Lac'", shape=1), @@ -770,13 +770,13 @@ def _check_cjk_shape_purity(self) -> None: shape=1), Case("leading_meng_is_a_given_name", "meng li", {"given": "meng", "family": "li"}, - notes="the control: the marking acts only at the slots S2 " + notes="the control: the marking acts only at the slots S4 " "reports from, and a leading word is none of them. No " "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 - # word, and S2's company clause lets an unambiguous credential IN + # 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. Case("comma_run_of_title_case_members_is_the_credential_run", @@ -884,7 +884,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith, PhD DO", {"given": "John", "family": "Smith", "suffix": "PhD DO"}, notes="#562's boundary: one 'DO' beside no other particle " - "is left to its capitals (S2), so the run stays settled " + "is left to its capitals (S6), so the run stays settled " "and silent, as 'John Smith, PhD MA' is. Untagged: every " "2.x release misread it, and its fix is #289's (#530), " "not #562's"), @@ -894,7 +894,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the lean that lets a settled run stand down is the " - "LISTED set's alone (S2): 'X.Y.Z.' joins the class by " + "LISTED set's alone (S6): 'X.Y.Z.' joins the class by " "shape and carries no writing to read, so the run is " "the count's and flips though both words are in " "capitals. 1.4.0 read given 'X.Y.Z.', family 'John " @@ -905,7 +905,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Smith", "suffix": "PhD MEng"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), - notes="S2's company clause: 'MEng' is written the way the " + notes="S7's company clause: 'MEng' is written the way the " "name lean reads, but the unambiguous 'PhD' in front " "of it anchors it, so the peel takes both and reports " "the member it picked. The roles are 1.4.0's and " @@ -960,7 +960,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "PhD Ma"}, classification="fix(#602)", ambiguities=("suffix-or-name",), - notes="rules/S2 (#602): the credential after two name words " + notes="rules/S8 (#602): the credential after two name words " "starts a run to the end of the part, the title inside it " "reads as a title, and the member is in the run. The id " "names the reading #544 pinned (middle 'Smith PhD Prof.', " @@ -973,7 +973,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "Ph. D."}, classification="fix(#540)", ambiguities=("suffix-or-name",), - notes="rules/S2's Accepted limit: the merged 'Ph. D.' is " + notes="rules/S7's Accepted limit: the merged 'Ph. D.' is " "read as a suffix at any position and so is not in the " "no-comma walk the company is read over, and the " "member's writing decides. The comma and given-slot " @@ -985,13 +985,13 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Smith", "suffix": "PhD Do Do"}, classification="fix(#602)", ambiguities=("suffix-or-name",), - notes="rules/S2 (#602): the credential starts the run and " + notes="rules/S8 (#602): the credential starts the run and " "P2's chain stops where it starts, so the 'Do Do' the " "chain joined is absorbed and reported. The id names " "#544's Accepted limit, which #602 retires; 1.4.0 and " "2.3.0 read middle 'Smith PhD', family 'Do Do'"), # #602: a credential after the name core starts a run to the end of - # its part (rules/S2). The movers, then the exclusions that keep + # its part (rules/S8). The movers, then the exclusions that keep # a run from starting -- each a negative control for the movers. Case("a_credential_starts_a_run_to_the_end_of_the_part", "John Smith PhD Jones", @@ -1120,7 +1120,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "Jan", "family": "vd Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), - notes="S2's company clause: 'vd' is particle AND suffix " + notes="S7's company clause: 'vd' is particle AND suffix " "vocabulary, and a particle is the head of the family " "name behind it rather than a credential that could " "speak for 'Ma', so the Title-case member's own writing " @@ -1145,7 +1145,7 @@ def _check_cjk_shape_purity(self) -> None: notes="#602: the credential starts the given part's run, so " "'vd' and 'Ma' are in it, the member reported. Before " "#602, and the reading the id names: " - "S2: a particle in front of a member takes it out of " + "S5: a particle in front of a member takes it out of " "the given part's trailing slot, and where the word " "behind reads as a name the two are one name, asked " "nothing and reporting nothing -- 'vd' reads as 'van' " @@ -1252,7 +1252,7 @@ def _check_cjk_shape_purity(self) -> None: {"family": "Smith de", "suffix": "MA"}, classification="fix(comma-precomma-family)", ambiguities=("suffix-or-name",), - notes="S2: in a mixed-case name the capitals speak even with " + notes="S6: in a mixed-case name the capitals speak even with " "no words to spare, so the never-given lead does not " "take 'MA' -- 'Smith, de Ma' does take it"), Case("an_ambiguous_particle_after_a_comma_keeps_the_given_name", @@ -1443,7 +1443,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the part is read wholly as credentials because 'PhD' " - "speaks for the Title-case 'Ma' (S2's company), and the " + "speaks for the Title-case 'Ma' (S7's company), and the " "pick is reported where the anchor made it. 2.2.0 " "and 2.3.0 read given 'PhD', middle 'Ma'; 1.4.0 through " "2.1.0 title 'PhD', first 'Ma'. 'Smith, PhD MA' reads the " @@ -2044,7 +2044,7 @@ def _check_cjk_shape_purity(self) -> None: "Smith, John PhD de", {"given": "John", "family": "de Smith", "suffix": "PhD"}, classification="fix(#379)", - notes="P6 over S2's given-part run (#602): a wholly-" + notes="P6 over S8's given-part run (#602): a wholly-" "particle piece is left to the walk and P6 attaches it, " "so neither stage reports. The run had absorbed 'de' and " "reported it, and P6 then reported the attachment as a " @@ -2056,7 +2056,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "de Smith", "suffix": "PhD, Jr."}, classification="fix(#379)", notes="P6: the run it looks past the post-nominal to " - "find is the same wholly-particle piece, so S2's run " + "find is the same wholly-particle piece, so S8's run " "leaves it alone wherever it stands. 2.3.0 read this " "way; 1.4.0 middle 'de'"), Case("a_particle_p6_does_not_attach_stays_in_the_run", @@ -2529,7 +2529,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="the one-case control for 'Jack MA': all-caps against " "all-caps is no contrast at all, so nothing leans and " - "rules/S2's words-to-spare count decides alone -- " + "rules/S4's words-to-spare count decides alone -- " "two pieces, so the acronym stays the family name", shape=1), Case("one_case_lower_ambiguous_takes_the_count", "jack ma", @@ -2588,7 +2588,7 @@ def _check_cjk_shape_purity(self) -> None: notes="the floor the lean does not move, measured " "2026-09-15: the trailing peel's walk starts after the " "leading title run, so one piece is all it sees and " - "rules/S2's two-piece floor refuses it. A lean that " + "rules/S4's two-piece floor refuses it. A lean that " "reached below the floor would read this as a title " "with a credential and no name at all -- and nothing " "is reported, because no fork was taken", @@ -2946,7 +2946,7 @@ def _check_cjk_shape_purity(self) -> None: "De La Cruz, M.J. PhD", {"given": "M.J.", "family": "De La Cruz", "suffix": "PhD"}, ambiguities=("suffix-or-name",), - notes="#563, S2's company rule: only a credential IN FRONT " + notes="#563, S7's company rule: only a credential IN FRONT " "speaks for a word, so the degree behind the initials " "leaves them the given name. Every release reads it " "this way; the report is new", @@ -2965,7 +2965,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 S2 " + "title of the given part they open (rules/C1, 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", @@ -3007,7 +3007,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#516)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="#563 second review round: 'Ed' is a listed ambiguous " - "member, and S2's company lets only an unambiguous " + "member, and S7's company lets only an unambiguous " "credential speak, so the comma keeps the family. 'G.J.' " "then ends the given part, the slot #563 left alone, " "where the dotted shape reads a credential as in 'Doe, " @@ -3149,7 +3149,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "van der Berg Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), - notes="rules/S2 says either reading carries the flag, and " + notes="rules/S4 says either reading carries the flag, and " "this reading carried none. assign reports from the " "peel's picks and a pick reaches it only as a LONE " "piece, so once the prefix chain merged 'Ma' into the " @@ -3435,7 +3435,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, S2). " + "default reads the comma position (rules/C1, S9). " "2.3.0 read given 'XYZ', family 'John Smith'"), Case("the_comma_caps_reading_can_be_switched_off", "John Smith, XYZ", @@ -4840,7 +4840,7 @@ def _check_cjk_shape_purity(self) -> None: "the walk stops at the declined pick rather than " "continuing past it -- so the all-caps 'MA' in front " "is never asked and never reports. ONE report, for " - "'Ma'. Identical in shape to rules/S2's accepted " + "'Ma'. Identical in shape to rules/S7's accepted " "'Jack Wei Ma' clause", shape=2), Case("a_taken_member_behind_a_declined_one_still_reports", @@ -5831,7 +5831,7 @@ def _check_cjk_shape_purity(self) -> None: "'MA' as a MIDDLE name -- releasing it from the clause would " "move a word from one person's name into another's. With the " "floor the clause keeps 'MA do' whole, and reports the 'do' it " - "kept. rules/S2's own 'Doe, John MA do' clause is the " + "kept. rules/S5's own 'Doe, John MA do' clause is the " "reading this rests on"), Case("the_clamp_never_takes_the_first_word_after_the_marker", "Doe, J. nee MA ba", @@ -6495,7 +6495,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "Ma", "middle": "Jones", "family": "John Smith"}, ambiguities=("suffix-or-name",), notes="the boundary of #603's opened part (rules/C1): only " - "a word that starts S2's run opens it, and a member of " + "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 " "post-comma piece reports the fork it read (#289)"), @@ -6900,7 +6900,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), classification="fix(#603)", notes="decisions/C1's accepted cost: a generational word " - "starts S2's run, so it opens the part and 'John' is " + "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 " "'John'"), @@ -9863,7 +9863,7 @@ def _check_cjk_shape_purity(self) -> None: {"title": "PROF.", "given": "JOHN", "family": "MA"}, ambiguities=("suffix-or-name",), classification="fix(#316)", notes="the row above in one case, where the capitals say " - "nothing and S2's reserve still decides: read as " + "nothing and S4's reserve still decides: read as " "'PROF. JOHN MA' is, 'MA' stays the family because " "taking it would leave none. 1.4.0 read last 'PROF.', " "suffix 'MA'"), @@ -10030,7 +10030,7 @@ def _check_cjk_shape_purity(self) -> None: {"title": "Prof.", "given": "abdul", "family": "rahman", "suffix": "MA"}, ambiguities=("suffix-or-name",), classification="fix(#316)", - notes="the row above's shape at S2's bare-ambiguous reserve, " + notes="the row above's shape at S4's bare-ambiguous reserve, " "which is the one place the reserve's own model of " "assign's reading and assign's reading disagreed. This " "is 'abdul rahman MA' plus a title, and that reads " @@ -10268,7 +10268,7 @@ def _check_cjk_shape_purity(self) -> None: "suffix": "MD van Jones"}, ambiguities=("suffix-or-name", "suffix-or-name"), classification="fix(#602)", - notes="rules/S2 (#602): inside the run a title word reads as " + notes="rules/S8 (#602): inside the run a title word reads as " "a title. #614's read merged 'van Secretary' into one " "piece and the title went into the suffix (its second " "review); since #620 the read counts the chain's units " diff --git a/tests/v2/design_docs.py b/tests/v2/design_docs.py index 92f47bc0..b7ffc816 100644 --- a/tests/v2/design_docs.py +++ b/tests/v2/design_docs.py @@ -29,7 +29,7 @@ # A citation: the folder and the key, or mechanisms.md and its slug. A # key is not followed by a word character, nor by a hyphen and another # word, which is how a file path in the rules folder (a stem like -# S2-case-signal-at-the-suffix-slot) is told from a citation, and a `.md` +# S6-case-signal-at-the-suffix-slot) is told from a citation, and a `.md` # after it marks a file name (the README); a sentence's full stop is # allowed, and so is a hyphen ending the line, where a comment wraps a # long key. A decisions key starting with a @@ -50,7 +50,7 @@ @dataclass(frozen=True) class IndexEntry: key: str - title: str # the link text, "S2 — the case signal at the suffix slot" + title: str # the link text, "S6 — the case signal at the suffix slot" path: Path diff --git a/tests/v2/pipeline/test_comma.py b/tests/v2/pipeline/test_comma.py index 1c140344..61150a8f 100644 --- a/tests/v2/pipeline/test_comma.py +++ b/tests/v2/pipeline/test_comma.py @@ -128,7 +128,7 @@ def test_paired_initials_need_a_word_to_speak_for_them() -> None: ("John Smith, X.Y. MA", "John Smith, X.Y. P.Q."), ("García Márquez, G.J.", "García Márquez, Jr G.J."), # a class member in front speaks for nothing, - # as S2's company has it; an unambiguous one does + # as S7's company has it; an unambiguous one does ("García Márquez, Ma G.J.", "García Márquez, PhD G.J.")): assert _decided(alone).structure is Structure.FAMILY_COMMA, alone assert _decided(spoken).structure is Structure.SUFFIX_COMMA, spoken @@ -204,7 +204,7 @@ def test_the_name_word_count_reads_a_run_as_it_reads_one_word() -> None: def test_a_run_the_writing_settles_raises_no_flip_report() -> None: # every member written in capitals in a mixed-case name leans - # credential (S2), so the part is the credential run on that + # credential (S6), so the part is the credential run on that # evidence rather than the count's: a suffix comma, and no flip # report. (Before #613 segment left this part to the listing form, # whose reading of a no-name part reached the same fields.) diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index 490d7dfa..a92b5f87 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -462,7 +462,7 @@ def test_where_the_marker_lands_when_the_consumer_declines() -> None: # any other to the bound-given join (P5 declines a marker "standing # as a word of its own", and this one is not) -- but since #602 the # 'Jr' behind it starts a credential run to the end of the part - # (rules/S2), so the reserve finds no name word to spare and the + # (rules/S8), so the reserve finds no name word to spare and the # join declines for that reason instead assert _piece_texts(_grouped("abdul van der Berg née Jr Jones")) == [ ["abdul", "van der Berg née", "Jr", "Jones"]] @@ -795,7 +795,7 @@ def test_the_join_declines_a_suffix_after_a_family_comma_too() -> None: def test_the_reserve_mirrors_the_bare_acronym_fork() -> None: - # S2's other positional fork: a bare ambiguous acronym is peeled + # S4's other positional fork: a bare ambiguous acronym is peeled # only with a given AND a family left. Read over the joined view # 'abdul Smith Jr Ma' is three pieces, so assign peels the acronym, # then the suffix, and one piece remains -- no family. The reserve @@ -938,7 +938,7 @@ def test_the_chain_keeps_a_numeral_the_peel_does_not_take() -> None: def test_the_chain_stops_before_a_bare_acronym_with_words_to_spare() -> None: - # S2's other fork, the same way: 'John Smith Ma' peels the acronym + # S4's other fork, the same way: 'John Smith Ma' peels the acronym # as a credential, so 'John van der Berg Ma' does too -- 1.4.0 read # suffix 'Ma' there, and 2.0 had let the chain take it. # diff --git a/tests/v2/pipeline/test_pieces.py b/tests/v2/pipeline/test_pieces.py index fc7b8e45..88698ac7 100644 --- a/tests/v2/pipeline/test_pieces.py +++ b/tests/v2/pipeline/test_pieces.py @@ -348,7 +348,7 @@ def test_peel_trailing_takes_the_three_lean_outcomes() -> None: # #289 at the trailing slot, the three outcomes against the peel's # three: a CREDENTIAL lean consumes with no words to spare, a NAME # lean declines WITH words to spare, and no lean at all leaves - # rules/S2's count deciding exactly as it did. + # rules/S4's count deciding exactly as it did. # Every case still REPORTS: the pick is appended either way, which # is what the fork's report is built from. for text, one_case, names, picked in ( @@ -853,7 +853,7 @@ def test_a_title_the_fixed_point_splices_out_keeps_no_peel_pick() -> None: assert name.ambiguities == () -# rules/S2 (#602): a credential after the name core starts a run to +# rules/S8 (#602): a credential after the name core starts a run to # the end of its part. The exclusion tests pass before the change too: # they are the negative controls that make the first two meaningful. def test_a_credential_after_two_name_words_starts_the_run() -> None: diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py index 714afc8c..c3efa0fa 100644 --- a/tests/v2/pipeline/test_post_rules.py +++ b/tests/v2/pipeline/test_post_rules.py @@ -29,7 +29,7 @@ particles=frozenset({"de", "der", "ibn", "la", "van", "vd"}), particles_ambiguous=frozenset({"la", "van"}), # `씨` is a ONE-LETTER suffix word, which starts no credential run - # (rules/S2's two-letter minimum -- any longer suffix word would, + # (rules/S8's two-letter minimum -- any longer suffix word would, # an honorific included since #679), the filler a test needs where # a credential would now take the rest of the part suffix_words=frozenset({"jr", "씨"}), @@ -832,7 +832,7 @@ def test_a_suffix_word_mid_run_ends_the_chain( # orders are pinned apart at a count other than two. The suffix word # is one letter because a longer one -- a credential, or since #679 # an honorific -- would start #602's run and take `Juan` with it - # (rules/S2); it was `MD` until #602. + # (rules/S8); it was `MD` until #602. out = _parsed("de Mesnil van Berg 씨 Juan", policy) assert _by_role(out, Role.FAMILY) == "de Mesnil" assert _by_role(out, Role.GIVEN) == given @@ -1067,7 +1067,7 @@ def test_a_surviving_name_word_parts_two_entries() -> None: test alone cannot reach: a name word standing between two post-nominals parts their entries. `abd` is suffix vocabulary that starts no credential run (it is bound given-name vocabulary too, - rules/S2), so `Jones` behind it survives as a name word before + rules/S8), so `Jones` behind it survives as a name word before the credential. Drop the conjunct and this renders 'abd PhD'. The fixture was 'Smith, MD - PhD - FACS' until #602, whose dashes diff --git a/tests/v2/test_benchmark.py b/tests/v2/test_benchmark.py index bc122208..f48222c4 100644 --- a/tests/v2/test_benchmark.py +++ b/tests/v2/test_benchmark.py @@ -259,7 +259,7 @@ def test_a_thousand_names_still_parse_in_reasonable_time( # frozen loop declines it on the tag # ('i und ' reaches nothing, 'i Und ' # reaches everything) -# S2 ANCHOR pass credential_run ONLY -- a Title-case +# S7 ANCHOR pass credential_run ONLY -- a Title-case # member in a mixed-case name declines on # its writing, so the peel asks # `credential_anchors` (#544), and every @@ -284,7 +284,7 @@ def test_a_thousand_names_still_parse_in_reasonable_time( "bound_given": "abdul ", # group: the P5 reserve over every piece "maiden_clause": "nee MA ", # group: M2's view over the segment "link_run": "i Und ", # group: P3's both-sides walk (#397) - "credential_run": "PhD Ma ", # pieces: S2's anchor pass (#544) + "credential_run": "PhD Ma ", # pieces: S7's anchor pass (#544) } _BASE = 800 @@ -1124,7 +1124,7 @@ def test_a_link_costs_what_it_is_pinned_at() -> None: def test_a_member_opening_a_comma_part_asks_no_anchor_pass() -> None: - """rules/S2's company clause speaks only for a member with a + """rules/S7's company clause speaks only for a member with a credential in FRONT of it, so a member opening the part after a one-word family comma ('Smith, Ed') is never anchored, and `segment_suffix_reading` builds no `credential_anchors` pass for diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index a4fa465a..eb688b2d 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -577,7 +577,7 @@ def test_the_bound_given_join_leaves_a_suffix_where_it_stands() -> None: ("abdul", "Smith", "Berg", "Ph. D.") # after a family comma the decline holds under the LENIENT reserve, # and since #602 the 'Jr' starts the given part's credential run, - # taking 'Smith' with it (rules/S2) + # taking 'Smith' with it (rules/S8) n = parse("Berg, abdul Jr Smith") assert (n.given, n.middle, n.family, n.suffix) == \ ("abdul", "", "Berg", "Jr Smith") diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index 38028ae1..5edfff4f 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -315,7 +315,7 @@ def _one_case(text: str) -> bool | None: def _qualifying_front(toks: list[tuple[Token, tuple[int, int]]], i: int, original: str) -> tuple[Token, int] | None: - """rules/S2's company clause: walk back from `toks[i]` (a + """rules/S7's company clause: walk back from `toks[i]` (a member of the ambiguous class) through lone members to the piece that would speak for their company, and return it -- with its OWN index, which a caller's role question needs -- if it structurally @@ -385,7 +385,7 @@ def _reserve_kept(toks: list[tuple[Token, tuple[int, int]]], j: int, def _outside_its_company(name: ParsedName) -> list[str]: - """rules/S2's company clause, read off a finished parse (#544): + """rules/S7's company clause, read off a finished parse (#544): a LISTED member of the ambiguous class written behind an unambiguous credential -- with nothing but listed members between them, in the same comma part and on the same side of a maiden @@ -421,8 +421,8 @@ def _outside_its_company(name: ParsedName) -> list[str]: ('Smith, PhD Ma') -- every head either has no comma at all or already carries a given name across it. That comma-side shape is pinned separately: `tests/v2/cases.py`'s `family_comma_lone_degree` - and `family_comma_two_credentials` rows, and rules/S2's - 'Smith, PhD' boundary example. + and `family_comma_two_credentials` rows, and rules/S7's + 'Smith, PhD Ma' example. DELIBERATELY A SECOND IMPLEMENTATION of `_pieces.credential_anchors`, over the finished parse's tokens, tags and roles rather than over @@ -467,7 +467,7 @@ def _credential_without_suffix_role(name: ParsedName) -> list[str]: outside both TITLE and SUFFIX. Every other member is decided by role tests alone. - Skips a member whose OWN case-based lean (rules/S2, #289 -- an + Skips a member whose OWN case-based lean (rules/S6, #289 -- an ALL-CAPS member of a mixed-case name) already reads it as a credential with no company at all: that class predates #544 and reaches the very same H4 carve-out on its own ('PhD MA' -> given @@ -521,7 +521,7 @@ def test_a_maiden_clause_does_not_change_how_a_trailing_word_reads( #544 joins this walk rather than opening a grid: every parse it takes is also asked `_outside_its_company` and - `_credential_without_suffix_role` (rules/S2's company clause, + `_credential_without_suffix_role` (rules/S7's company clause, both directions), and the two heads ending in 'PhD' were added so that the given slot holds members behind a credential beside the comma-less trailing slot 'Jane Doe Jr.' already held. A third diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index 448143bc..1b6f01f6 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -661,7 +661,7 @@ def test_the_gate_and_the_parser_read_a_cased_suffix_differently() -> None: repairs it -- keeping the connective lowercase -- where the one-case 'john e jones iii' reads 'e' as an initial. The gate follows the parse's suffix call, the ambiguous credential class included, so - 'jack MA' (S2's lean read MA as the credential) and 'MD, PhD' + 'jack MA' (S6's lean read MA as the credential) and 'MD, PhD' (family MD, suffix PhD) repair on the default path too.""" mixed = parse("john e jones III") one_case = parse("john e jones iii") @@ -1680,7 +1680,7 @@ def test_initials_separator_is_honored_on_the_live_token_path() -> None: def test_a_caps_credential_after_a_comma_keeps_its_capitals_in_every_setting( ) -> None: - # A credential read by its capitals (S2) repairs to its all-caps + # A credential read by its capitals (S6) repairs to its all-caps # spelling (rules/R4). #564 made the comma reading a default # decided in `segment` from the text, and classify's shape tag -- # what repair reads -- has to follow it there too, or the default From 909e17fed55a26e495a39f86704f867b23213c73 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 17 Oct 2026 22:11:35 -0700 Subject: [PATCH 2/5] Split decisions/S2 into one history file per rule (#682) The 40 entries go to the rule each one decided: S2 keeps the trailing read (#614, #620, #625); S3 takes the roman-chunk retirement and the digit-dotted tokens, whose history had been filed under S2 too; S4 the count and the slots that report; S5 the given part's trailing slot (#531, #573, #578); S6 written case and the #289 + #516 arc's review and measurement rounds; S7 company (#544); S8 the credential's run (#602, #627, #679); S9 the by-shape class (#516's switches, #563, #564). Entry text moves unchanged except the 16 positional references the move broke ("the bullet above", "this section's item (ii)"), now key-form pointers, and one already stale before the move (the `### M2 above` left by #713). Each file closes with a dated note on the split, and every decisions/S2 pointer in code, tests and other entries is retargeted to the file now holding what it meant. Co-Authored-By: Claude Opus 5.5 --- .../decisions/C1-suffix-comma-decision.md | 2 +- .../decisions/P6-trailing-orphan-particle.md | 2 +- docs/design/decisions/R5-case-repair-gate.md | 2 +- docs/design/decisions/README.md | 9 +- .../S2-case-signal-at-the-suffix-slot.md | 108 ------------------ ...-ends-a-name-and-never-opens-the-string.md | 25 ++++ ...edentials-written-with-interior-periods.md | 5 + ...-ambiguous-acronym-needs-words-to-spare.md | 9 ++ ...arts-trailing-slot-after-a-family-comma.md | 8 ++ .../S6-case-signal-at-the-suffix-slot.md | 28 +++++ ...S7-company-of-an-unambiguous-credential.md | 14 +++ .../decisions/S8-credentials-trailing-run.md | 13 +++ ...-that-join-the-ambiguous-class-by-shape.md | 26 +++++ docs/design/decisions/generational-words.md | 2 +- docs/design/mechanisms.md | 4 +- .../rules/C2-parts-past-the-second-comma.md | 2 +- ...edentials-written-with-interior-periods.md | 2 +- ...-ambiguous-acronym-needs-words-to-spare.md | 2 +- ...arts-trailing-slot-after-a-family-comma.md | 2 +- .../S6-case-signal-at-the-suffix-slot.md | 2 +- ...S7-company-of-an-unambiguous-credential.md | 2 +- .../rules/S8-credentials-trailing-run.md | 2 +- ...-that-join-the-ambiguous-class-by-shape.md | 2 +- nameparser/_pipeline/_assign.py | 2 +- nameparser/_pipeline/_classify.py | 2 +- nameparser/_pipeline/_pieces.py | 4 +- nameparser/_pipeline/_segment.py | 2 +- nameparser/_pipeline/_state.py | 2 +- nameparser/_pipeline/_vocab.py | 8 +- nameparser/config/suffixes.py | 2 +- tests/v2/cases.py | 46 ++++---- tests/v2/pipeline/test_assign.py | 2 +- tests/v2/pipeline/test_comma.py | 2 +- tests/v2/pipeline/test_group.py | 6 +- tests/v2/pipeline/test_pieces.py | 2 +- tests/v2/pipeline/test_post_rules.py | 2 +- tests/v2/test_parser.py | 8 +- tests/v2/test_policy.py | 2 +- tests/v2/test_properties.py | 6 +- 39 files changed, 199 insertions(+), 172 deletions(-) delete mode 100644 docs/design/decisions/S2-case-signal-at-the-suffix-slot.md create mode 100644 docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md create mode 100644 docs/design/decisions/S3-credentials-written-with-interior-periods.md create mode 100644 docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md create mode 100644 docs/design/decisions/S5-given-parts-trailing-slot-after-a-family-comma.md create mode 100644 docs/design/decisions/S6-case-signal-at-the-suffix-slot.md create mode 100644 docs/design/decisions/S7-company-of-an-unambiguous-credential.md create mode 100644 docs/design/decisions/S8-credentials-trailing-run.md create mode 100644 docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md diff --git a/docs/design/decisions/C1-suffix-comma-decision.md b/docs/design/decisions/C1-suffix-comma-decision.md index 6dce8958..b81f7cc3 100644 --- a/docs/design/decisions/C1-suffix-comma-decision.md +++ b/docs/design/decisions/C1-suffix-comma-decision.md @@ -49,7 +49,7 @@ 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/S2 (#564), where the caps shape's switch has always been decided; this bullet is C1's pointer to 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. diff --git a/docs/design/decisions/P6-trailing-orphan-particle.md b/docs/design/decisions/P6-trailing-orphan-particle.md index 0c2f24f2..6cfa7c8d 100644 --- a/docs/design/decisions/P6-trailing-orphan-particle.md +++ b/docs/design/decisions/P6-trailing-orphan-particle.md @@ -52,7 +52,7 @@ Decided 2026-08-19 (was Open: [#380](https://github.com/derek73/python-nameparse WHAT REVIEW FOUND, because the corpus could not. Four agents on PR #472 found the site firing outside the order it was reasoned from, in two directions, and the differential corpus reported neither: 52 default-order names (a conjunction stops a particle's forward chain and leaves it standing in a middle — `Maria Luisa y de la Cruz` gave family 'de la Cruz' where master gives 'la Cruz') and 366 FAMILY_FIRST_GIVEN_LAST folds of a particle that does not end the name (`de Anke van y` gave family 'van de Anke' with the given name still behind it). Of 1099 corpus names only 6 hold a conjunction-then-particle adjacency and none in this shape, so the REACH recipe honestly reported 0 default-order movers throughout. The count was right and the population was blind; "none under the default order" was a property of the corpus, not of the code. Both are now `· boundary` examples in rules.md so the doc runner holds them. The repair is one clause — the run must END the name — and it subsumes the order test a first repair added: measured over 542,592 generated parses, an explicit `state.order[0] is Role.FAMILY` never decides anything the ends-the-name test has not already decided, so it is not in the code. What IS kept is `state.order is not None`, which is what keeps this site off the family-comma path (0 co-firings with P6's comma site over ~892,000 parses). A stale index list was repaired with it: O1 and O2 retag between roles without recomputing, and this site was the first consumer of `families` after them (33,840 stale observations instrumented). No shipped input reaches a wrong answer through it — no `_TURKIC`/`_EAST_SLAVIC`-matching word is particle vocabulary — so the fix is unwitnessed by any test and is recorded here instead. -- 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/S2'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-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). 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. diff --git a/docs/design/decisions/R5-case-repair-gate.md b/docs/design/decisions/R5-case-repair-gate.md index 05263d27..640cffe8 100644 --- a/docs/design/decisions/R5-case-repair-gate.md +++ b/docs/design/decisions/R5-case-repair-gate.md @@ -14,7 +14,7 @@ - 2026-09-23 #492 — DECIDED (Derek, 2026-09-23): the gate reads every token but the SUFFIX-roled ones. R5 says mixed case is evidence the writer cased the NAME deliberately, and a suffix written the way a suffix is written — `III`, `PhD`, `Jr.` — says nothing about how the writer cased the name words. So `juan garcia III` repairs to `Juan Garcia III` where every release, 1.4.0 through 2.3.0, returned it untouched, and `juan garcia PhD`, `JUAN GARCIA Jr.` and `dr. juan garcia III` repair the same way. v1's test for it, `test_capitalization_exception_for_already_capitalized_III`, was a known failure since 2012 — added 2012-10-07 (e2df6937) as a plain failing test against the Google Code tracker's issue 22, marked xfail 2026-09-01 (d02dc41e) — and this change retires the marker and the `_KNOWN_FAILURE` suffix the test carried. Every token not roled SUFFIX stays in the gate text, NICKNAME and MAIDEN included: `jane doe nee SMITH III` and `juan garcia (Bob)` are left alone (pinned in `tests/v2/test_render.py::test_the_gate_leaves_the_suffixes_out`, where a mutant filtering those roles too survived the suite otherwise). A name whose non-suffix tokens are caseless is one-case exactly as the whole name was, so only the suffix repairs (`김민준 phd` → `민준 김 PhD`); a name with NO non-suffix token is one the parse never produces (rules/H4 — measured, `Jr.` is a title, `PhD` a given name, `MD, PhD` family and suffix), so only a synthetic `ParsedName` reaches the empty text, which is one-case, and every suffix repairs to its own fixpoint. Force is unchanged. WHY "USE FORCE" IS NOT THE ANSWER: `force=True` is lossy on correctly cased names — `Danny DeVito` → `Danny Devito`, `Shia LaBeouf` → `Shia Labeouf`, `Anne-Marie DuPont` → `Anne-Marie Dupont`, `Bill deBlasio` → `Bill Deblasio` (re-measured 2026-09-23) — so a batch caller cannot force the lowercase rows without damaging the cased ones beside them. -- 2026-09-23 #492 — THE R5 / PARSER SPLIT, decided and recorded rather than left for a reader to discover. The parser's own one-case readings — rules/P3's single-letter connective and rules/S2's case signal at the suffix slot — still count a cased suffix, as Derek's 2026-09-19 comment on #492 and decisions/S2's 2026-09-14 bullet require (the judged token stays in the span: `Jack MA` is mixed because of `MA`); R5's gate does not. So one name can be mixed to the parser and one-case to repair. `john e jones III` is mixed to P3, `III` included, so its `e` reads as a connective and it repairs to `John e Jones III`, while `john e jones iii` is one-case, reads `e` as an initial and repairs to `John E Jones III`; the corpus name `john e jones, III` moves the first way on the default path, where every release left it untouched. The gate follows the parse's suffix call wherever the parse makes one, the ambiguous credential class included, so two shapes outside the corpus move on the default path as well: `jack MA` gives `Jack MA` — S2's lean read `MA` as the credential, and the gate then leaves it out — and `MD, PhD` gives `Md PhD`, an input that is nothing but vocabulary (rules/H4's shape) read as family `MD` and suffix `PhD`, where 2.2.0 and 2.3.0 left `MD PhD` untouched; the family `MD` repairs as a name word, by the item-4 decision under R4. All four strings are pinned by `tests/v2/test_render.py::test_the_gate_and_the_parser_read_a_cased_suffix_differently`. +- 2026-09-23 #492 — THE R5 / PARSER SPLIT, decided and recorded rather than left for a reader to discover. The parser's own one-case readings — rules/P3's single-letter connective and rules/S6's case signal at the suffix slot — still count a cased suffix, as Derek's 2026-09-19 comment on #492 and decisions/S6's 2026-09-14 bullet require (the judged token stays in the span: `Jack MA` is mixed because of `MA`); R5's gate does not. So one name can be mixed to the parser and one-case to repair. `john e jones III` is mixed to P3, `III` included, so its `e` reads as a connective and it repairs to `John e Jones III`, while `john e jones iii` is one-case, reads `e` as an initial and repairs to `John E Jones III`; the corpus name `john e jones, III` moves the first way on the default path, where every release left it untouched. The gate follows the parse's suffix call wherever the parse makes one, the ambiguous credential class included, so two shapes outside the corpus move on the default path as well: `jack MA` gives `Jack MA` — S2's lean read `MA` as the credential, and the gate then leaves it out — and `MD, PhD` gives `Md PhD`, an input that is nothing but vocabulary (rules/H4's shape) read as family `MD` and suffix `PhD`, where 2.2.0 and 2.3.0 left `MD PhD` untouched; the family `MD` repairs as a name word, by the item-4 decision under R4. All four strings are pinned by `tests/v2/test_render.py::test_the_gate_and_the_parser_read_a_cased_suffix_differently`. - 2026-09-23 #492 — TITLES STAY EVIDENCE, and the reason is a limit of title REPAIR rather than a claim about titles. Title repair is still plain `str.capitalize()`, and widening the gate to titles would surface its defects on the default path. Measured under `force=True`, where they already show (re-measured 2026-09-23): `Lt.Gov. juan e garcia` → `Lt.gov. Juan e Garcia`, `The Lord of the Universe` → `the Lord of the Universe`, `Dr. med. univ. Margit Popp` → `Dr. Med. Univ. Margit Popp`. So `Dr. juan garcia` is left alone (rules/R5's boundary line). This may move when title repair is trusted. diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index 608d032e..2c7272eb 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -35,7 +35,14 @@ Entry conventions: ## Suffixes: generational & credentials (S) -- [S2 — the case signal at the suffix slot](S2-case-signal-at-the-suffix-slot.md) +- [S2 — a suffix word ends a name and never opens the string](S2-suffix-word-ends-a-name-and-never-opens-the-string.md) +- [S3 — credentials written with interior periods](S3-credentials-written-with-interior-periods.md) +- [S4 — the ambiguous acronym needs words to spare](S4-ambiguous-acronym-needs-words-to-spare.md) +- [S5 — the given part's trailing slot after a family comma](S5-given-parts-trailing-slot-after-a-family-comma.md) +- [S6 — the case signal at the suffix slot](S6-case-signal-at-the-suffix-slot.md) +- [S7 — the company of an unambiguous credential](S7-company-of-an-unambiguous-credential.md) +- [S8 — the credential's trailing run](S8-credentials-trailing-run.md) +- [S9 — unlisted words that join the ambiguous class by shape](S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md) ## Nicknames & quoted names (N) diff --git a/docs/design/decisions/S2-case-signal-at-the-suffix-slot.md b/docs/design/decisions/S2-case-signal-at-the-suffix-slot.md deleted file mode 100644 index 1c44ffca..00000000 --- a/docs/design/decisions/S2-case-signal-at-the-suffix-slot.md +++ /dev/null @@ -1,108 +0,0 @@ -# S2 — the case signal at the suffix slot - -- 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. -- 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 — 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-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) — #516's DOTTED HALF IS A SWITCH, `Policy.unlisted_dotted_suffixes`, DEFAULT ON: an unlisted token of two or more period-separated chunks, any chunk length (`X.Y.Z.`, `B.Tech.`, `Q.W.E.R.T.`), joins the ambiguous class BY SHAPE; whole-token vocabulary still wins (`M.A.`, `Ph.D.`, `A.B.C.` — `abc` IS a suffix acronym). The positional rule then decides and reports either way: `John Smith X.Y.Z.` → suffix, `Jack X.Y.Z.` → family, `Smith, A.B.` → given, `John Smith, A.B.` → suffix. Case is irrelevant — the periods are the evidence — so `john smith x.y.z.` reads as its mixed-case twin. Switch OFF: name material everywhere as 2.3 read it, and the fork is STILL reported, the parser having chosen the name reading over a credential one. -- 2026-09-15 (Derek) — THE ROMAN-CHUNK ACCIDENT RETIRES, NARROWLY. rules/S3's chunk rule survives except where every chunk the vocabulary matches is a SINGLE ASCII CHARACTER — the accident exactly. Measured, `{w for w in (L.suffix_acronyms | L.suffix_words) if len(w) == 1}` is `{i, v, 2}` plus the glued CJK honorific tails `{様, 殿, 氏, 군, 님, 씨, 양}`, so "single ASCII character" names the first three and only those — CHARACTER because `2` is a digit and is in the set, ASCII because `씨` is the one that must KEEP its chunk claim (`J.씨`). So `John Smith R.A.I.` and `John Smith J.u.n.i.o.r.` reach the shape class and read suffix by POSITION with the same fields plus the report, `Jack X.Y.I.` reads family where it read suffix (1.4.0's reading), and `Msc.Ed.`, `JD.CPA`, `Lt.Gov.`, `J.씨` keep their chunk-derived readings. The WIDE retirement — any chunk match yielding to the shape — was MEASURED AND REJECTED: it moves three more corpus names, `Doe, John Msc.Ed.` losing a real credential to `middle` and the two `김민준씨, J.씨` rows re-routing their honorific peel, for nothing this design wants. -- 2026-09-17/18, #516 review rounds — THE SHAPE VERDICT NEEDS TWO FURTHER GATES, both found by measuring after the shape verdict shipped. (i) ALPHABETIC: `period_joined_vocab`'s "shape" branch now requires every chunk to be `.isalpha()` — a bare digit chunk is not an acronym letter by any reading, so `Smith, 1.4`, `John Smith 1.4` and `John Smith, 1.4` stay unclaimed (no report, no role move), where an unguarded shape verdict had wrongly admitted them; the delimited `Bridge (1.4)` control only proved digits reach extract's escape, saying nothing about the same digits undelimited, which is the gap this closes. (ii) INITIALLESS-SCRIPT: the same test `is_title_shaped` already makes for H2's own leading-abbreviation inference (#323) — `text.isascii() or not in_initialless_script(text)` — now gates the shape verdict too, so `John Smith 田.中.`, `김 민준 이.박.` and `John Smith たな.か.` stay family: a script with no period abbreviations at all has nothing for interior periods to abbreviate, so a CJK word glued into period-separated characters is not spelling an acronym either. `John Smith 田.中.` is pinned `tolerated=True` rather than `shape=` — a Latin period convention glued onto CJK characters is a composed form no writing system produces, and the case table's own validator refuses a Latin shape tag on CJK text; `이.박.` and `たな.か.` are covered by direct `period_joined_vocab` assertions instead of a second corpus row each. -- 2026-09-17, #516 review round — `SMITH, J.R.` READS BY SHAPE FOR A REASON UNRELATED TO WHY IT DECLINES THE LISTED WORD. `_normalize` keeps interior periods ON PURPOSE (v1 parity, pinned 2026-07-17: `'J.R.'` must not collapse to the listed suffix word `'jr'`), so `ambiguous_class_member` correctly answers False for `Smith, J.R.`, `Smith, E.S.Q.` and `Smith, E.T., Jr.` for a reason that has nothing to do with #516. What #516 adds is a SEPARATE route: each chunk (`'j'`/`'r'`, `'e'`/`'s'`/`'q'`, `'e'`/`'t'`) is an unlisted single-letter chunk nothing in the vocabulary claims, so the token joins the class BY SHAPE instead, exactly as `'K.D.'` does in `J.A. K.D.`. The READING is unchanged in all three — one pre-comma name word is never enough to flip the structure — but each now gains `suffix-or-name`, the class having been considered and declined rather than never reached at all. - `is_wholly_suffix` NEVER READS THE BY-SHAPE CLASS. A first version admitted a by-shape member there unconditionally, reasoning it was needed for `John Smith, A.B.`'s structure flip; proved wrong by mutation — that flip already goes through `_segment.py`'s own `pre_comma_names >= 2` disjunct alone, and nothing else in the suite depended on the admission except this predicate's own two unit tests. Left in, the admission combined with C1's LEGACY token-count disjunct (which counts WORDS, not NAME words, and predates the name-word-count decision above) to flip `Smith Jr., A.B.` to given `'Smith'`, suffix `'Jr., A.B.'` with a self-contradicting "holds 1 name words" report. Fixed by dropping the admission outright: the by-shape class reaches the comma form ONLY through `_vocab.ambiguous_class_candidate`, never through `is_wholly_suffix`. `Smith Jr., A.B.` now reads given `'A.B.'`, family `'Smith'`, suffix `'Jr.'` — the same shape the LISTED class reads for `Smith Jr., Ma` and `Smith Jr., ma` (given `'Ma'`/`'ma'`, family `'Smith'`, suffix `'Jr.'`), not for `Smith Jr., MA` (given `''`, family `'Smith'`, suffix `'Jr., MA'` — the caps lean has already moved that one into the suffix run, measured on this tree; at 2.3.0 it read given `'MA'`, family `'Smith'`, suffix `'Jr.'`, the very shape `Smith Jr., A.B.` reads now) — with the report; classification `parity`, not `fix(#516)`: FULL PARITY on the 1.4.0 wheel (first A.B., last Smith, suffix Jr.), only the report being new. -- 2026-09-14 (Derek), corrected 2026-09-18 — #516's ALL-CAPS HALF IS A SWITCH TOO, `Policy.unlisted_caps_suffixes`, DEFAULT OFF, and the asymmetry is the decision. An unlisted all-caps word of two or more letters, with no period ANYWHERE (`isalpha()` rejects a period anywhere, not merely an interior one, so a single TRAILING period, H2's own shape, is unaffected either way), in a name `one_case` says is written in more than one case, joins the class by shape (`_vocab.caps_shape_candidate`). - UNLISTED MEANS IN NO WORDLIST AT ALL, not merely "no whole-token suffix vocabulary" — found only by measuring after a narrower, tag-based guard shipped and reversed: `titles`, `given_name_titles`, `particles`, `particles_ambiguous`, `conjunctions`, `conjunctions_ambiguous`, `bound_given_names`, `suffix_acronyms`, `suffix_acronyms_ambiguous`, `suffix_words` and `maiden_markers` — ELEVEN lists, checked by direct membership rather than by tag, since `segment` calls the shape test before `classify` has tagged anything. Measured with the narrower guard alone (`John Smith DE`, `John Smith NEE` wrongly moved to suffix), and stated here as a recipe rather than a fixed count, since a raw per-list membership tally and a de-duplicated one (a word appearing in two lists, `abu`/`أبو`/`ابو` across `bound_given_names` and `particles_ambiguous`, is one leak, not two) give different totals: `len([w for w in getattr(lexicon, name) if w.isascii() and len(w) >= 2 and w.isalpha()])` per excluded list, run over the shipped `Lexicon.default()`, gives on the order of dozens for `particles` and `particles_ambiguous` and single digits for `conjunctions` and `bound_given_names` — every one of them a wordlist entry the caps shape test's earlier, narrower guard did not check at all. The one figure that reproduces exactly, because ASCII case is what the test needs and every other maiden marker is a non-Latin or non-ASCII spelling the shape test's own `isupper()`/`isalpha()` gate excludes on its own: FOUR maiden markers are ASCII (`geb`, `geboren`, `geborene`, `nee`), out of `len(lexicon.maiden_markers)` total (17, measured 2026-09-18) — `'NEE'`/`'GEB'` are two of those four, capitalized, which is what the narrower guard let through. ACCEPTED GAP, recorded rather than chased: an all-caps German title spelled with `SS` for `ß` (`GROSSFÜRST`) is unlisted by this same lookup too, for a reason predating this switch entirely — `_normalize` uses `lower()`, never `casefold()`, specifically so it does not corrupt `großfürst` to the misspelling `grossfürst`, and classify's own existing `vocab:title` check has never recognized this spelling either. Left for whoever next generalizes the ß-fold question. - THE RUN IS THE CAPS CLASS'S ALONE. The multi-token shape `'LEED AP'` needs a RUN test (`John Smith, LEED AP` → suffix `'LEED AP'`), but the listed and dotted halves are single-token classes by construction — the listed set is inherently bare words and the dotted half never needed more than one token — so testing the run over either of them asks a question this design never posed. An un-narrowed run test measured three reversals before this was caught: `John Smith, Ed Ma` (both listed, Title-case, which #289's OWN lean reads as NAME), `John Smith, ma do` (both listed, lower) and `John Smith, X.Y.Z. A.B.` (both dotted) all wrongly moved to suffix as a run. Narrowed to the caps branch alone; `Smith, LEED AP` still declines and reports, and all three reversals are now pinned identical OFF/ON. - `JACK VI` AND `JOHN SMITH VI` DO NOT MOVE, AND NOT FOR THE REASON A FIRST DRAFT GAVE. `VI` passes the caps shape test and carries both `SHAPE_ACRONYM_TAG` and `vocab:suffix-ambiguous` with the switch on — the predicate reaches it, contrary to what an early write-up of this entry said. What actually holds it unchanged is rules/S2's OWN roman-numeral fork (`_pieces.is_trailing_numeral_suffix`), which claims a bare `'VI'` piece and decrements the peel's walk BEFORE the bare-ambiguous/shape branches beneath it are ever reached — a mechanism this switch does not touch and never held `VI` back through non-membership. - THE COST IS A TWO-WORD CONTRAPOSITIVE, not a flat one. Off by default because French and Korean records write the SURNAME in capitals — `Jean DUPONT`, `Minjun KIM` and `Jean Pierre DUPONT` all read family at 1.4.0, at 2.3 and at this default — and `isupper()` is script-agnostic, so the same decline holds in ANY script with a case contrast (`Иван ИВАНОВ`, `Jean ÉCOLE`, measured). Turning it on does not cost every candidate alike: at TWO words there are no words to spare, so `Jean DUPONT` and `Minjun KIM` report the fork and KEEP their family — the class is considered and declined, the mirror of the listed lean's own words-to-spare rule. At THREE words there ARE, and `Jean Pierre DUPONT` gives family `'Pierre'`, suffix `'DUPONT'` — the one genuine cost, and a swallowed family name is the worse of the two failures #vocabulary-collisions C-i already argues from. Default-off is 1.4.0 parity for the whole class regardless; on, nothing but the report changes at two words, and only the three-word shape actually moves. Off, NOTHING is reported either. -- 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. - 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-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): - fn(name); calls=0 - def counter(frame,event,arg): - nonlocal calls - if event=='call': calls+=1 - sys.setprofile(counter) - try: - for _ in range(n): fn(name) - finally: sys.setprofile(None) - return calls/n -off=Parser(); on=Parser(policy=Policy(unlisted_caps_suffixes=True)) -for n in ('Smith, John','Smith, XYZ'): print(n, calls_for(off.parse, n), calls_for(on.parse, n))"` (both cheaper than an unconsolidated spelling would be, after the caps predicate was consolidated into one shared function, `_vocab.caps_shape_candidate`, called from classify, `ambiguous_class_candidate` and `_segment.py`'s run test alike — the usual "a shared call costs the default a frame" objection does not apply, because every caller's own first conjunct is `policy.unlisted_caps_suffixes` itself, False by default; `tests/v2/test_policy.py`'s own dated comment carries the same two figures and the recipe for `tools/perf/call_count.py` reference readers). The band never moved at either policy — `test_benchmark.py` gates only the comma-less reference name, which no comma-path cost can reach — and `_CALL_BASELINE` is untouched throughout. - NO MECHANISMS ENTRY IS OWED, and here is why: the promoted fact is STATE-OFFSET-CHANNELS' "a pre-token fact is recorded on the ParseState and consulted later" applied to a name-level boolean rather than to offsets, and `ParseState.order` is the precedent that entry and ONE-PREDICATE-PER-QUESTION both already name. That entry's own sentence — "recorded as offsets … or by presence alone where the fact is name-level" — already covers it; the one edit made there is to name `one_case` as the third channel. -- 2026-09-15 (Derek) — ACCEPTED, NOT REPAIRED, TWO SHAPES. (i) `Freiherr von Berg MA` MOVES: it was listed as unreachable by the peel and as staying fix(#424)'s, and measured, the caps lean makes it reachable, because the lean ends P2's chain with no words to spare where the count would not. Today title 'Freiherr', family 'von Berg MA'; designed title 'Freiherr', family 'von Berg', suffix 'MA'; 1.4.0 first 'von Berg', last 'MA'. Three readings exist and the designed one ships; it moves a rules/P2 example line and re-scopes fix(#424)'s ledger rule, whose `fields` gains `suffix`. (ii) `abdul Smith Jr Ma` reads `Jr` as a MIDDLE NAME: the surname lean breaks the peel AT `Ma`, so the unambiguous `Jr` in front of it is never reached. Today given 'abdul', family 'Smith', suffix 'Jr Ma'; designed given 'abdul Smith', middle Jr, family 'Ma'. One case row rather than real data, but the shape is real — a name-leaning ambiguous acronym BLOCKS the peel of a genuine suffix behind it — and the walk stopping at the declined pick is the rule, not a bug the peel owes a repair. -- OUT OF SCOPE, each its own issue or entry: no tokenizer split (`J.R.R.`, `A.B.` stay single tokens); the trailing-period-only shape is NOT the signal (`John Smith Xyz.` → family, and AGENTS.md's trailing-abbreviation paragraph stands for bare words and sentence-final stops); no new `AmbiguityKind`; no locale packs; #492 stays deferred. NEITHER SWITCH REACHES THE v1 SHIM, and that is a consequence stated rather than repaired: `lenient_comma_suffixes` has no v1 manager either and `_build_snapshot()` simply does not translate it. Switch A is DEFAULT ON, so `HumanName` changes with it and a v1 caller cannot turn it off — the deliberate reading of the bridge, where the facade tracks the parser's defaults and 2.x behavior changes reach it (the #528 precedent). -- 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 BLAST RADIUS IS THE GATE'S OWN COUNT, NOT A PREDICTION. Measured at 2.3.0 (`uv run python tools/differential/compare.py`, the default baseline, the previous release), and RE-MEASURED after the review round later the same day and again after the verification round that followed it, which is why several sets of figures carrying one date would otherwise sit here: corpus 1234 names, intentional diffs 51, unexplained 0, radar unclassified 7 — SUFFIX_OR_NAME rows the gate reports but no rule yet claims, named so a release note is never quantified from the intentional count alone. The round added TEN corpus names, not the eight first written here (corrected in the verification round of 2026-09-18): `Doe, John X.Y.Z.`, `Dr. 김민준씨, MA`, `John Smith, MD, Ma`, `John Smith, MD, R.A.I.`, `John de Ma`, `Jo김민준씨, DO`, `Kim김민준씨, MA`, `STEVEN HARDMAN, MD, DO, DDS`, `anh van mc`, `毛泽东 MA` — case rows and rules.md example lines both. The verification round itself then added THREE more (`Anh van Do`, `Doe, John MA`, `John Smith, MD, XYZ`, all rules.md boundary examples), taking the gate's count at this baseline from 1231 to 1234. Recompute the delta rather than trusting either figure: load every `tools/differential/corpus*.jsonl` through compare.py's own `_load_entries`, dedup by name in file order, and diff that list against the same list built from `git show :tools/differential/corpus*.jsonl`. That by-name count runs four below the gate's at the 2.x baselines, the gate deduping by (name, order) and four entries carrying a declared order; the DELTAS agree, which is what a "the round added N" sentence is claiming. The round also added a FOURTH rule, `fix(#289/#516) the glued CJK honorific peel reads the case lean` (3 composed CJK names: `Dr. 김민준씨, MA`, `Jo김민준씨, DO`, `Kim김민준씨, MA` — radar-tier by demotion and classified anyway, since a radar diff this arc intended is one a release note may be written from). The four rules carry 20 + 9 + 9 + 3 = 41 of the 51, `John de Ma` having joined the first. The pre-round reading of this paragraph, kept for the shape of the argument rather than the digits: `fix(#289) a written case contrast decides a bare ambiguous acronym` (19 names, incl. `Davis Royce, Ed`, `Freiherr von Berg MA`, `Jack MA`, `Jack Wei Ma`, `John Prof. MA`, `John Smith Ma`, `John Smith, Ed`, `John Smith, MA`, `John Smith, Ma`, `John van der Berg Ma`); `fix(#516) an unlisted dotted acronym is read by position` (9: `Jack X.Y.I.`, `John Smith Q.W.E.R.T.`, `John Smith X.Y.Z.`, `John Smith, A.B.`, `john smith x.y.z.`, `John Smith B.Tech.`, `John Smith C.H.A.`, `John Smith E.S.Q.`, `Smith, E.S.Q.`); `fix(#289/#516) the ambiguous credential class reports at slots that were silent` (9: `John Smith J.u.n.i.o.r.`, `Royce, Ed`, `Smith, A.B.`, `Smith, Ma`, `J.A. K.D.`, `Jack X.Y.Z.`, `John Smith R.A.I.`, `Smith Jr., A.B.`, `Steven Hardman, MD, DO, DDS`). This is LARGER than the plan's original "11 role + 4 report-only" prediction at the default policy — entirely by corpus growth (this arc's own commits admitted the case-row texts above as contract-tier names) and by two mechanisms the runtime prototype that produced that figure predated (`ambiguous_class_candidate`'s existence and the family-comma report's shape-tag branch), never by a wider rule. Recompute with the command above and read the count the gate returns; the numbers here are a snapshot dated 2026-09-18, not a promise. -- 2026-09-18, WHOLE-PR REVIEW ROUND — WHAT IT MOVED, MEASURED BEFORE AND AFTER. Ten findings; the five that changed behavior are here with the shape each was found by, since the shape is what generalizes. (1) A LOST REPORT. Where the prefix chain absorbed an acronym the S2 peel had DECLINED, nobody emitted the flag S2 promises for either reading: `assign` reports from `peel_trailing`'s picks and a pick reaches it only as a LONE piece, so the token it would have reported on no longer existed. Before: `John van der Berg Ma`, `John de Ma`, `Dr. John van Smith Ma`, `John van Smith Ma Jr.` and `John Smith Mc Ma` gave their family names with `ambiguities` empty; after, each names `suffix-or-name` once and no role moves. The emitter is at the chain's own merge and declines where the PARTICLE run took the word (`anh van do`, `anh van mc` — P6's fork, not S2's, and 1.4.0 read both silently). Recompute: parse the five names and read `ambiguities`. (2) TWO STAGES ASKING ONE QUESTION WITH DIFFERENT INPUTS. `_script_segment` asked `is_wholly_suffix` of the post-comma run WITHOUT the `one_case` fact `segment` had recorded one stage earlier, so `Kim김민준씨, MA` read family `Kim김민준씨`, suffix `MA` (씨 unpeeled) while `Kim김민준씨, PhD` read family `Kim김민준`, suffix `씨, PhD` — one name, two credential spellings, two divisions. After: both peel. `Jo김민준씨, DO` and `Dr. 김민준씨, MA` move with them; `김민준씨, MA` does NOT, being wholly one case (Hangul is caseless and `MA` contrasts with nothing), which is the doctrine holding rather than an exception. rules/W3's claim that the two stages differ "in what else they require, not in what they ask of the words" was false until this and is true now. (3) A REPORT ABOUT THE PARSER'S OWN NEW READING. rules/S3's narrow retirement moved `R.A.I.`, `X.Y.I.` and `J.u.n.i.o.r.` from the vocabulary verdict into the shape class, and `segment`'s tail-segment test reads `is_wholly_suffix`, which is blind to that class — so `John Smith, MD, R.A.I.`, `John Smith, Ph.D., R.A.I.` and `John Smith, MD, J.u.n.i.o.r.` gained a `comma-structure` flag 2.3 did not raise. After: quiet, at the default. The test is narrowed to the BY-SHAPE half deliberately — a LISTED member reaches that reading through the case lean, and `STEVEN HARDMAN, MD, DO, DDS` (one case, nothing leans) keeps its flag as the lean's own recorded negative control; `John Smith, MD, Ma` keeps it too. With `unlisted_dotted_suffixes=False` the flag stands, the token being name material there. C2 is amended to state all of this. (4) THE WORDLIST ROSTER. `_vocab.caps_shape_candidate`'s exclusion named ELEVEN of `_lexicon._VOCAB_FIELDS`' thirteen: `Lexicon.default().add(surnames={"dupont"})` with the caps switch on still read `Jean Pierre DUPONT` as given `Jean`, family `Pierre`, suffix `DUPONT` — a caller listing a word as a SURNAME and getting a credential, which is this switch's own worst failure arriving through the one wordlist that says "family name". After: family `DUPONT`, no report. The roster is derived from the field tuple, with a per-field test; `honorific_tails`, the other omission, was already excluded transitively (⊆ `suffix_words` by Lexicon invariant) and joins for completeness. (5) A LISTED MEMBER READ BY SHAPE. A caller may list a DOTTED entry in `suffix_acronyms_ambiguous` ('a.b'), and the whole token then matches that set while `suffix_as_written`'s period-free acronym lookup ('ab') misses it — so the chunk view reached classify's shape branch, `SHAPE_ACRONYM_TAG` rode along, and `_pieces.listed_lean` (which declines wherever that tag is) silenced the caller's own case lean: `Jack A.B.` read family where `Jack MA` reads suffix. After: suffix `A.B.`, the lean applying. Nothing default moves, the shipped ambiguous vocabulary carrying no periods. Also in the round and behavior-neutral: `Policy.unlisted_dotted_suffixes` and `unlisted_caps_suffixes` moved to the END of `Policy` and `PolicyPatch` — inserted mid-class, they had re-bound every positional argument from `strip_emoji` on, which is API for a dataclass that is not `kw_only`; the bool-validation roster is derived from `dataclasses.fields`, the hand-written twin in the tests having named neither new flag; `ambiguous_class_candidate` lost an optional `one_case` parameter no production caller ever passed, and with it a caps branch that answered False for every name the library has parsed; and `one_case` is REQUIRED on the four piece predicates that own it. -- 2026-09-18 — DIGIT-DOTTED TOKENS MOVE, SILENTLY, AND IT IS ACCEPTED. The narrow retirement's set of single-ASCII-character suffix entries is `{i, v, 2}` — the roman numerals AND the lone digit — so a dotted token whose only chunk match is that digit loses the chunk claim, and the SHAPE class cannot take it either: `period_joined_vocab`'s shape verdict wants every chunk `isalpha()`, which no digit is. Measured against 1f78bef: `John Smith 1.4.2` suffix `1.4.2` → family `1.4.2`; `Smith, 1.4.2` family `Smith`/suffix `1.4.2` → given `1.4.2`/family `Smith`; `John Smith, 1.4.2` given `John`/family `Smith`/suffix `1.4.2` → given `1.4.2`/family `John Smith`. No report at any policy, and the switch does not reach it either way. ACCEPTED: a version string read as a credential was the same accident the retirement removes, and the three readings are pinned as case rows. `John Smith 1.4` was already family at 1f78bef and is unaffected — two chunks, neither a match. docs/release_log.rst's wording is corrected from "a single-character roman numeral" to the digit-inclusive form, which is what the code tests. -- 2026-09-18 — THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE, AND THE GIVEN SEGMENT'S TRAILING SLOT IS AN OPEN FOLLOW-UP. `assign`'s family-comma emitter reads `state.pieces[1][0]` — the first piece after the comma — so a class member standing at the END of the given part is never asked about. PRE-EXISTING and unchanged by this arc: `Doe, John MA` reads middle `MA` and `Doe, John X.Y.Z.` middle `X.Y.Z.`, both silent, at 1f78bef and on this tree alike. What this arc adds is names that MOVED into that silence: `Doe, John J.u.n.i.o.r.` and `Doe, John X.Y.I.` went suffix → middle, and `John Smith nee Jones R.A.I.` suffix → maiden (the maiden walk absorbs it). `Freiherr von Berg X.Y.I.` was listed here too and does not belong: corrected in the verification round of 2026-09-18, it moved suffix → family but GAINED a report rather than losing one. At 1f78bef and on the 2.3.0 wheel it read title `Freiherr`, family `von Berg`, suffix `X.Y.I.`, reporting `particle-or-given` alone; on this tree it reads title `Freiherr`, family `von Berg X.Y.I.` and reports `particle-or-given` AND `suffix-or-name`, the chain emitter taking it by its shape tag. The role moved into a slot that does not report and the report came from the chain instead, so the name is evidence for the emitter rather than for the silence. The universal claims are narrowed to what ships rather than the emitters widened: `nameparser/_types.py`'s `SUFFIX_OR_NAME` member now names the slots it covers and the one it does not, and rules/S3's "and the same at a comma" now says which parts of a comma form it means. Widening it is a MAINTAINER DECISION and is left open, filed as [#531](https://github.com/derek73/python-nameparser/issues/531): the slot is `_assign`'s given-part walk, the fork is genuine there, and whether a middle initial's neighbourhood should start reporting is a judgement about noise (rules/A1's "a kind is worth adding only if a reader would hesitate too" applies to a SLOT the same way). And the open question is a 1.4.0 PARITY question as well as a noise one, which the verification round of 2026-09-18 established and the entry had backwards: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel and middle `MA` at 2.0.0 through 2.3.0 and here, so the silence at this slot is a 2.0-era reading that has never matched v1 — "every release" was the wrong span and it is now "every 2.x release" wherever this is stated (`nameparser/_types.py`'s `SUFFIX_OR_NAME` member, docs/release_log.rst). The dotted sibling does not divide that way: `Doe, John X.Y.Z.` is middle `X.Y.Z.` at 1.4.0 too. So a maintainer widening the slot is choosing between reporting the fork and restoring v1's ROLE, which are different repairs, and only the bare-acronym half has a parity claim behind it. Recorded under 3-0-reevaluations' standing rule that a design citing 1.4 parity as load-bearing says so where the design lives. Two more report movements recorded here rather than repaired: `van MA` LOSES its `particle-or-given` (1f78bef reported both kinds; the caps lean now takes `MA` to suffix, leaving `van` the only name piece, and assign's leading-particle emitter wants more pieces following), and `John Smith Ma Ma` reports ONCE where 1f78bef reported twice (the first `Ma` is a middle name now, not a second declined pick). And a reach of the default-on dotted switch worth naming: `John Smith www.example.com` reads suffix `www.example.com` with a report, where 1f78bef read family — a URL is three alphabetic period-separated chunks and the shape rule cannot tell it from an acronym. -- 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-18 — THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT, kept because the failure mode is this log's recurring one. A review round called THREE `one_case` plumbing sites in `_group.py` inert and proposed removing them. Measured with a runtime wrapper over 1,642 names under six policies — the default, both family-first orders, strict commas, and each 2.4 switch flipped — 9,852 parses: the maiden walk's PAIR is inert, 0 parses move, and it is inert BY CONSTRUCTION rather than by corpus luck (`numeral_only` answers off `peeled.numeral`, and the numeral fork is decided before the peel reads a lean). The chain-tail measure is not: dropping it there moves 18, on `John van der Berg Ma`, `John de Ma` and `Freiherr von Berg MA` under every one of the six. Both figures are at the call sites. The population is the DISTINCT union, in file order, of every `tools/differential/corpus*.jsonl` entry, every `tests/v2/cases.py` text, and `tests/test_variations.TEST_NAMES` with the three comma permutations `test_variations_of_TEST_NAMES` builds (no-comma, family-comma, and suffix-comma where the name has a suffix), empty strings dropped; the wrapper patches the call sites in a copy of the module source and rebinds `_pipeline.STAGES`, which binds stage functions at import and would otherwise leave the mutation inert. This entry first carried 2,338 names and 14,028 parses, which that recipe does not reproduce by any reading of it — the distinct union is 1,642, summing the three sources' distinct counts gives 2,202, and not deduping at all gives 2,690 — so both figures were re-measured on this tree in the verification round of 2026-09-18 and restated here and at the call sites. The FINDING was unharmed: 0 and 18 reproduce exactly, on the same three names, under all six policies, and the control that drops all four sites at once moves those same 18, which is what shows the wrapper can report a difference at all. The argument was right for two sites out of three and would have taken a third with it. -- 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 above 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 above 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 the recipe above. -- 2026-09-18, VERIFICATION ROUND — THE REPORT INVENTORY AN UPGRADER SEES, 2.3.0 → TREE, AND ITS RECIPE. docs/release_log.rst's comma-report bullet claimed TWO reports go away and named `Steven Hardman, MD, DO, DDS` and `John Smith, MD, R.A.I.`. The second is false and was never measured against the wheel: `John Smith, MD, R.A.I.` reports NOTHING at 2.3.0, having carried a `comma-structure` flag only inside this arc's own development, where rules/S3's narrow retirement raised it and the C2 amendment quieted it again. Over the whole corpus exactly ONE report is lost, and one more is SWAPPED. Measured: LOST — `comma-structure` ×1 (`Steven Hardman, MD, DO, DDS`), `given-or-family` ×2 (`Jack X.Y.I.`, and `e and e`, which is PR #527's conjunction change and not this arc's). GAINED — `suffix-or-name` ×37, `conjunction-or-initial` ×9 (#527's again), `given-or-family` ×2 (`Jack MA`, `Jack MA.`). So `Jack X.Y.I.` is the only name whose report CHANGED KIND on this arc's account, `given-or-family` → `suffix-or-name`, and the bullet now names it as the swap rather than counting it among the losses. Recompute: collect the corpus glob through compare.py's `_load_entries` deduped by name, parse each name on the tree and on the 2.3.0 wheel (a PEP 723 `uv run --no-project` script pinning `nameparser==2.3.0`, per AGENTS.md's warning that a same-tree comparison reports parity), and take the per-name multiset difference of the `ambiguities` kind values in both directions. The kind counts are multisets, not name counts — `e and e` contributes two `conjunction-or-initial` gains on its own — which is why a bullet quantified from the gate's per-rule name lists would not answer this question. -- 2026-09-18 (Derek), #531 — CAPITALS DECIDE FOR `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING. `do` is the one member of the ambiguous credential class (`ba`, `do`, `ed`, `jd`, `ma` — recompute with `sorted(Lexicon.default().suffix_acronyms_ambiguous)`) that is also particle vocabulary, so the trailing slot of the given part and P6's attachment want the same word. Where the writing carries a positive credential lean — an all-caps member in a name written in more than one case — the credential reading wins and P6 stands down: `Doe, John DO` reads suffix `DO` and reports `suffix-or-name`. Every other spelling attaches exactly as it did before, with P6's own `particle-or-given` and no second report: `Doe, John do`, `Doe, John Do`, `DOE, JOHN DO`, `doe, john do` all read family `do Doe` in their own case. THE PAIRING IS THE ARGUMENT, and the accepted cost and the reason are the two halves of it. In ONE CASE the rule cannot tell `NASCIMENTO, EDSON ARANTES DO` from `SMITH, JOHN DO` and reads both as the particle — right about the Brazilian record, wrong about the osteopath, whose `DO` 1.4.0 read as a suffix; both report `particle-or-given`, which is how a caller finds the second. In MIXED CASE it can, and does: `Nascimento, Edson Arantes do` keeps family `do Nascimento` while `Doe, John DO` gives suffix `DO`. Accepted rather than repaired: the one-case reading is the commoner of the two collisions, and the Nascimento record is the name a wrong answer would damage. The other four members are not particles and are unaffected. This is the exception rules/P6's "this attachment outranks the suffix reading (S2)" clause now names. Implementing it takes TWO carve-outs rather than one, which is worth recording because the second is in the REPORT and not the reading: the reading carve-out takes a particle-tagged member only on a POSITIVE credential lean, since `Doe, John do` leans nothing and the positional reading would otherwise take it — the wrong ANSWER, not merely a stray report; and the emitter beside it skips a particle-tagged member it did not itself take, since an emitter gated on tag membership alone reported both kinds on every `do` row. Where P6 owns the fork, P6 reports it, which is mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE read strictly. ONE SHAPE THE PAIRING DOES NOT REACH — a shape, not a spelling, and the difference is the review finding: ANY particle or particle chain standing in front of the caps `DO` takes it out of this slot, because grouping's prefix chain merges the two into ONE particle piece before the slot exists and there is then no lone member for the capitals to lean on. It is a fact about this WORD being particle vocabulary itself, not about particles generally — the caps `MA` in `Doe, John van MA` is not swallowed, reads as the credential, and leaves `van` to attach behind it. `Doe, John van DO` reads family `van DO Doe`, and so do the `de`, `von`, `del`, `la` and `van der` spellings of it (measured 2026-09-19 — parse them and read `family`). Pre-existing and unmoved by #531; `Doe, John van DO` is the case row and rules/P6's example line, and P6's statement carries the carve-out. The mirror shape is silent rather than merely unreached and is recorded on `AmbiguityKind.SUFFIX_OR_NAME`'s fourth boundary: a particle BEHIND the member ends the given part where this walk reads it, so `Doe, John MA do` keeps middle `MA` beside family `do Doe` though the capitals would otherwise have taken it, the attachment that moves `do` running a stage later. -- 2026-09-18 (Derek), #531 — THE COMMA REPORT'S REACH NOW INCLUDES THE GIVEN SEGMENT'S TRAILING SLOT, AND THE OPEN FOLLOW-UP IS CLOSED. The 2026-09-18 bullet above headed THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE recorded this slot as an open maintainer decision and named the two questions it turned on — whether a middle initial's neighbourhood should start reporting (a noise judgement, rules/A1) and whether the silence was a 1.4 parity gap. Both are answered here, and that bullet stands as it landed. The slot now reads and reports: `Doe, John MA` gives suffix `MA` and `Doe, John Ma` keeps middle `Ma`, each saying which way it went. Derek chose to restore the ROLE and report both ways — one rule for both spellings — over a report-only change and over a capitals-only one, because the issue exists in the first place because two spellings of one name disagree. The noise question was settled by MEASURING THE DISAGREEMENT rather than by argument: over a generated sweep of 78 pairs — five listed members and two by-shape tokens in three cased spellings each, plus five controls in one spelling apiece, so 26 words against three name shapes — 48 pairs disagreed about whether the word was a credential or a name, and every one of the 48 disagreed in the same direction, the comma form declining what the comma-less form took. After this change 3 disagree and all three are the lower-case `do` rows P6 owns. The sweep and its allowlist are `tests/v2/test_properties.py`; a slot that answers differently from the same name written without a comma is not a quiet slot, it is an inconsistent one. Recorded under `3-0-reevaluations`' standing rule because v1 parity is LOAD-BEARING for one half of this and explicitly NOT for the other: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel, so the bare-acronym half RESTORES v1's role and the report is all that is new there — its 1.4.0 ledger rule neither retired nor narrowed to `_ambiguities` (nothing below baseline 2.0 can diff on that pseudo-field) but was RE-POINTED to the four names where the writing declines the credential, which keep the 2.0-era middle name against v1. `Doe, John X.Y.Z.` reads middle `X.Y.Z.` at 1.4.0 too, so the dotted half LEAVES v1 and carries a 1.4.0 ledger rule of its own; it moves to match the comma-less `John Doe X.Y.Z.` and rules/S3's shape rule, not to restore anything. BLAST RADIUS, stated the way this log's own rule asks: twenty corpus names move, and only TWO of them were in any corpus before this branch (`Doe, John MA` and `Doe, John X.Y.Z.`, both admitted by #530's own arc) — the other eighteen are this change's own case rows, so what the differential measures on pre-existing data is two names, and the population the rule reaches is a SHAPE (every family-comma listing whose given part ends in a member of this class) that the corpora barely sample. ACCEPTED COSTS, all measured on the differential corpora: `SMITH, JOHN DO` keeps family `DO SMITH` where 1.4.0 read suffix `DO`, paired with the Nascimento record in the bullet above; one-case and caseless names take the credential with no case evidence at all, so `DOE, MARY JO MA`, `doe, john ma`, `田中, 太郎 MA` and `김, 민준 MA` all read a suffix, which is what 1.4.0 read for each of the four — "caseless is inert" holds for the LEAN and not for the outcome, since `is_one_case` answers True for a script with no case and the positional reading then decides; `Doe, John van MA` loses its middle to the family, reading family `van Doe`, suffix `MA` with two reports where it read middle `van MA` in silence, which is `Berg, Jan van Jr.`'s reading arriving through a shape it could not reach before (Derek accepted it 2026-09-18 as P6 working correctly rather than as a cascade to carve out); `Doe, John Prof. MA` gains a TITLE role `Prof.` never had, H5's transparency reaching it once `MA` leaves the walk, landing it on the same answer as `Doe, John MA Prof.`; `Smith, LEED AP` moves under the default-off caps switch alone, to given `LEED`, family `Smith`, suffix `AP` with two reports, so no default reading is at stake; and a genuine middle name that is also a class member is now a credential wherever it ends the given part and is not Title-cased — `DOE, JOHN ED` reads suffix `ED` — which is the same cost the comma-less form has carried since 2.0. NOT REPAIRED HERE and left open: the maiden walk claims `Smith MA` whole in `Doe, Jane nee Smith MA` before this slot exists, so the rule cannot reach it and the name stays silent with maiden `Smith MA` — the same gap #530's close-out saw from the other side with `John Smith nee Jones R.A.I.`, and it is out of scope for #531. -- 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 the entry under `### M2` above is where its reasoning lives. The gap the bullet above left open as out of scope for #531 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, the 2026-09-14 #289/#516 entry at the head of this section, 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. -- 2026-09-23 #492 — THE DEFERRED QUESTION the 2026-09-14 predicate paragraph and the 2026-09-19 #533 bullet above name — whether a cased suffix token counts as case evidence — IS ANSWERED FOR RENDER ONLY. R5's gate now leaves the suffixes out (decisions/R5, 2026-09-23); the parse-time fact this section rests on keeps counting the judged token exactly as written above, so `Jack MA` still leans credential and `DOE, JANE nee Smith Ma` still reads suffix `Ma`. The split is decided and recorded under R5, with the shapes it makes visible there (`jack MA` at this slot, `john e jones III` at P3's). -- 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 this section'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. - 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 this section'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: this section's 2026-09-15 ACCEPTED item (ii) (the bullet after this entry says so); the three "wrongly moved" reversals the 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. - 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. -- 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 CAPITALS DECIDE FOR `do` above 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-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. - 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. - 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. - 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-02 (Derek), #573 — a never-given particle opening the given part after a family comma leaves no word to spare; the member behind it is the surname it heads, unless mixed-case capitals say credential. The record is the P6 section's entry of the same date. -- 2026-10-02 (Derek), #578 — A PARTICLE BETWEEN A CREDENTIAL AND A TITLE-CASE WORD STAYS SILENT, AS IT READS. `Doe, Jane PhD vd Ma` reads given 'Jane', middle 'vd Ma', family 'Doe', suffix 'PhD', reporting nothing. No new rule decides that: S2's given-part slot already says a particle in front of a member takes the member out of the slot, and where the word behind reads as a name the two are one name, asked nothing and reporting nothing — `vd` reads as `van` does (`Doe, Jane PhD van Ma`, the same reading), and S2's own boundary example is `Doe, John van Ma`. The writing is malformed whichever reading is meant: a Title-case `Ma` behind a lowercase `vd` behind a `PhD` is neither a Dutch listing nor a credential run as anyone writes one. A reader leaning anywhere leans suffix (Derek: both words follow a known credential, and the writer likely did not know how suffixes are capitalized), but that is a guess about a writer's mistake, not a convention the writing carries. Declined: (a) reading `vd Ma` as suffixes, which needs code to make a credential speak across a particle — against P2 (a particle joins the word behind it) and #554 (`vd` speaks for nothing behind it); (b) reporting the fork, which needs an emitter on the family-comma path for a decision the particle chain makes before assign knows which read the part gets (AGENTS.md: prefer leaving a fork silent and documenting the omission over machinery for input nobody reads with confidence). Where the writing does speak the parser already follows it: `Doe, Jane PhD vd MA` reads suffix 'PhD vd MA' and reports (#573). Pinned by the case row `a_particle_after_a_credential_heads_a_title_case_name`. -- 2026-10-04 (Derek), #602 — A CREDENTIAL AFTER THE NAME CORE STARTS A RUN TO THE END OF ITS PART. An unambiguous Latin suffix word (the Latin restriction retracted by #679, below) of two or more letters standing after two name words with no comma (a lone particle not counting toward the two; qualified by #627, below), or after the given word in the part after a family comma, makes every later word of that part a suffix, except a title word, which reads as a title: `John Smith PhD Jones` → suffix 'PhD Jones', `Eric H. Holder Jr. Attorney General` → suffix 'Jr.', title 'Attorney General'. A name word the run absorbs is reported `suffix-or-name`, a reader hesitating over `Jones` there. Before this the three comma shapes disagreed — middle 'Smith PhD' with no comma, middle 'Jones' and suffix 'PhD' after a family comma, and suffix 'Jr., Jones' after a suffix comma (C2's tail) — and the no-comma reading made a credential a middle name, the one reading nobody intends. The suffix-comma path already read it this way, and the comma spelling of the Holder name always did. - WHAT STARTS THE RUN is one predicate, `_pieces.starts_a_credential_run`, read by the trailing peel (`credential_run`, applied to every finished peel, so assign, P5's reserve, P2's chain stop and the maiden walk see one answer) and by the given-part walk. EXCLUDED, each with its reason: a TITLE WORD — 746 title words are in no suffix set (measured 2026-10-04: `len(L.titles - L.suffix_acronyms - L.suffix_words)` for the default lexicon), many of them surnames (`King`, `Bishop`, `Judge`), so `Mary Jane King Smith` keeps family 'Smith'; a title is read from the end of a name only behind a comma or with a period (H5, Derek). The AMBIGUOUS CLASS, which carries no evidence of which it is. A SINGLE LETTER, in any case: a lowercase `i` or `v` carries no `initial` tag, and found by the v1 off-switch test, `Josep Carod i Rovira` with `i` removed from the conjunctions read suffix 'i Rovira'. A CONNECTIVE. A word that is also PARTICLE or BOUND GIVEN-NAME vocabulary — found by the suite, not foreseen: `abd` is in the credential list (ABD) and heads `Abd Allah`, and `Mohamed Ali Abd Allah` read suffix 'Abd Allah' until it was excluded. The NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one (retracted by #679, below). A word that is also a title (`MD`, `Ms`, `Sr.`) does start the run: position decides, and after the name core it is the suffix. - DECLINED: lifting the credential out — `John Smith PhD Jones` as given 'John', middle 'Smith', family 'Jones', suffix 'PhD', the family-comma path's old reading. It keeps every name word in a name field, but it reads a credential as something written in the middle of a name, which is the reading the rule rejects. - REVERSED BY THIS ENTRY rather than edited: the #578 bullet above (`Doe, Jane PhD vd Ma` now reads suffix 'PhD vd Ma', the credential starting the given part's run), and two of the three shapes #544's ACCEPTED LIMITS kept out of the company's reach — a title standing between the credential and the member, and a member a particle chain had taken. The case rows `a_title_between_degree_and_member_keeps_it_a_name` and `a_member_the_particle_chain_took_is_out_of_reach` keep their ids and now pin the run. The third, the merged `Ph. D.`, stays out of reach: that piece is outside the walk the run is read over. - MEASURED 2026-10-04, every name in `tools/differential/corpus*.jsonl` parsed on this tree and on aa401d80 with `nameparser.__file__` asserted on each side: 7 of 1,514 move, four of them this entry's own new rules/S2 examples. The three already in the corpus: `Jane van der Berg née Jr Jones` (suffix 'Jr Jones', where the marker's declined clause had left middle 'van der Berg née Jr'), and `John Doe, MD - PhD - FACS` and `Smith, MD - PhD - FACS`, whose dash inside the given part, a word there under every policy (#549), is now in the run: suffix 'PhD - FACS' where they read middle '-', suffix 'PhD, FACS'. The comma-part question — whether a part OPENING with a credential makes the comma a suffix comma (`John Smith, PhD Jones` still reads family 'John Smith') — is #603's. - -- 2026-10-07 (Derek), #614 — S2'S TRAILING RUN IS READ ONCE, BETWEEN P3'S CONNECTIVE JOINS AND THE PARTICLE CHAIN, AND BINDS. The issue's diagnosis held on the day: the words-to-spare count is taken over pieces and group's joins make pieces, so group re-asked the peel before and after its own joins -- the particle chain's stop and its re-ask and rollback, the bound-given reserve's two views (P5) -- and assign then read the run again. Now the run of a main segment (no comma, or the part before a suffix comma) of three or more pieces is read once, after the Ph. D. merge, the maiden take and P3's connective joins, which do not depend on the run and make the units it counts, and before the particle chain and the bound-given join, which do; the run is split off, the chain and the join group the name in front of it, and the run is put back unchanged (`_pieces.read_trailing_run`). The chain's stop is the end of what is left and the reserve counts the name pieces the read left; assign places and reports the run off the read (`ParseState.tail_reads`, `_assign._placed`). The read COUNTS a particle run and the plain name words after it as the one unit the chain will make of them (`_counting_view`, a copy; rules/P3's own count, mechanisms.md#UNIT-PARTITION), the words both particles and suffix vocabulary straight behind a particle among them, while anything the read weighs stays a word of its own -- suffix vocabulary, the ambiguous class and its shape-only members, roman numerals by shape, initials, period-marked titles. - STALE IN THE ISSUE, measured before designing: segment no longer decides the comma or emits S2 reports (#613 moved C1 to `_comma.decide`); assign predicts P1's fold but no longer P6, which it decides since #613; the two comma restart inputs (`John Smith, PhD vd DO`, `PhD DO DO`) no longer reach the chain, decide binding that part first; `credential_run` already applied in one place; and #613's `name_word_count` reads particle and suffix facts at P1's reach and ignores connective and bound-given joins, so S2 could not share it as it stood. The issue's 'licensed by neighbour', read literally, moves about 25 inputs (`JOHN SMITH MA`, six S3 example lines) and still five read as scoped to the duals, so it was not taken; the counting view's particle run is the one part of it the design needed. - THE FIRST DESIGN AND THE REVIEW THAT REVERSED IT. Derek decided first that the read WINS OVER EVERY JOIN, connectives included -- prototyped both ways in a scratch copy -- and that implementation passed the suite, the gates and the fingerprint. #614's PR review then found it broke what the corpus never tests: a salutation alone read its connective as the family name (`Mr. and Mrs.` gave title 'Mr. Mrs.', family 'and' -- the H5 chain took 'Mrs.' before P3 could join), a joined leading salutation counted as three words (`Mr. and Mrs. Jr` gave family 'and', `Mr. and Mrs. Do` a spurious report), and the read counted a particle chain's or a connective join's words one by one where rules/P3 says a joined part is one name word wherever another rule counts (`Freiherr von Berg X.Y.Z.` and `Juan y Garcia X.Y.Z.` took the acronym as a credential against every release, while `Freiherr Berg X.Y.Z.` kept it the family). DECIDED (Derek, 2026-10-07): connectives join first, and the read counts the chain's units -- which returns every one of those to master's reading, and most of the first design's 'tail beats connectives' class with them. A second-round finding the counting view itself made: folding the plain words after a particle into its unit swallowed a shape-only roman numeral (`Juan de la Vega VI` read family 'de la Vega VI'), the #610 class AGENTS.md's spelling sweep records, so a word the numeral fork reads by shape stays out of the unit. - DECIDED BEFORE THE REVERSAL AND STANDING: (1) the read wins over the particle chain and the bound-given join; (2) the consequences below, accepted as a class. - WHAT MOVES. One realistic reading: a trailing period-marked title behind a particle chain is the title, `John van der Berg Prof.` reading title 'Prof.', family 'van der Berg' (every release 1.4.0-2.3.0 and 0c54662b: family 'van der Berg Prof.'), which retires the cost rules/H5 recorded as accepted; the six heads of the stress grid below that end in a particle chain move the same way with `Dr.` or `Prof.` behind them. The garbage `Freiherr von Berg Dr. and Ed. Prof.` reads title 'Freiherr von Berg Dr. and Ed.', family 'Prof.' (0c54662b family 'von Berg Dr. and Ed. Prof.'): the title is read off before the chain, which then joins a piece carrying the joined title and reads as a leading title, so assign reads the name again. P5 no longer re-reads the joined view: `abdul J. V` and `Sir abdul J. V` read given 'abdul J.', family 'V' -- 1.4.0's reading restored; 2.0 and 2.1 read suffix 'V', 2.2 and 2.3 kept 'J.' a middle name -- the initial in front suppressing the numeral fork in the one reading taken, three name words standing and the join leaving two; and `Sheik abdul Ma` reports the pick the read weighed (decisions/P5, this date). Reports: a pick the particle chain takes into the name is reported by the chain, in its own words, and group takes it out of the read's picks so assign does not report it again (#614's /simplify: assign first skipped a pick it found already reported, inferring group's decision from group's output), so `Jan vd Ma`, `John de Ma`, `John van der Berg Ma` and `Nguyen Van Lac` report as on 0c54662b. The second review found why it must be the chain: assign words a pick by the role it holds before post_rules, and under a leading title-particle that is the given role the fold then moves, so with assign reporting `Freiherr von Berg Ma` told the caller 'read as a given name' of a word in the family. The chain also reports a member the read never weighed, one it takes short of the run, its scan ending at the next particle (`Freiherr von Berg Ed Do` reports 'Ed'). One report moves for the better: a pick the run keeps is reported as the suffix it reads as, `Juan y Garcia Mc Do MD van Ed` reporting 'Ed' 'read as a suffix' where 0c54662b said the particle chain took it into the name, of a word in the suffix. - THE SECOND REVIEW (bfa798fb, 2026-10-07) found four defects in the counting view and the report hand-off, every one on a shape the corpus, the gates and the first stress grid held no instance of, and each now has a case row that dies without its fix. (1) The read started at the end of the leading titles counted in PIECES, applied to the view: a title that is also a particle opens a unit inside the titles, so the index landed past the name and `Freiherr St van Berg MA` read family 'St van Berg MA' (0c54662b suffix 'MA'); the view now returns where the name starts in its own indices -- the first unit opened at or before the titles' end, since a unit opened inside them makes a name of them (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: the chain now opens no unit inside the titles) even where it ends short (`Freiherr Freiherr Prof do` lost its report on 'do' until that second half). (2) Assign reported the chain's picks, in the wrong field's words, above. (3) The view made units INSIDE #602's credential run, which hid a title word from the run's title test: `John Smith MD van Secretary Jones` lost title 'Secretary' to the suffix. Past a run starter a unit is now the bare particle run, as the chain's own merge leaves it. (4) A `title` piece-tag exclusion in `_counted_as_one` made garbage differ from 0c54662b (`Freiherr von Mr. and Mrs. X.Y.Z.`) and guarded nothing (3) did not; removed rather than pinned. - NOT THE SAME READ AS M2'S: the maiden take reads its own clause-free view of the words before any join, word by word, as rules/M2 states, and its Accepted clause already covers what that does where a particle chain would take the run; S2's read counts the chain's units after P3's joins. Two reads of one name answer different questions -- where the clause ends, where the name's run starts -- and are kept apart rather than made one here. - RESIDUALS, the paths that read as before #614: a segment of fewer than three pieces (no join reaches it); a run with a name piece inside it (an H5 title in front of a name word the peel declined, `John Dr. G.J.`, `John van V Dr. V`), which is not split -- group re-asks and assign reads for itself; and a name group's joins leave without a name word in front of the run, which assign reads again so it keeps one -- the same procedure as before #614, not always the same answer, the Freiherr garbage above being the measured case. - MEASURED 2026-10-07 against 0c54662b (#618's merge), py3.11, `nameparser.__file__` asserted on each side; #619, merged between, moved no reading. The two #617 harnesses as decisions/P3's 2026-10-06 entry records them (the fingerprint, 78,324 parses; the connective grid, 485,300), and an S2 stress grid built for this entry: the 22 heads `John`, `John Smith`, `John Q. Smith`, `Mary Ann Smith`, `John van Smith`, `Juan de la Vega`, `John van`, `anh van`, `Jan Freiherr von Berg`, `Freiherr von Berg`, `abdul salam`, `abdul rahman al-said`, `Juan y Garcia`, `Josep Carod i Rovira`, `Jane Doe nee van der Berg`, `Dr. John Smith`, `JOHN SMITH`, `john smith`, `Smith`, `de Mesnil`, `Kim Min Jun`, `Ortega y Gasset`, each followed by every one- and two-word tail over the 35 words `PhD MA Ma DO Do do vd VD mc Mc MD Jr Jr. Sr III V V. VI I X.Y.Z. XYZ Ph. D. Esq. Prof. Dr. Sir MBA RN G.J. Ed van and Jones Ed. ba` (`Ph. D.` one word of the list); then 25,000 draws seeded `random.seed(614)`, each `choice(heads)`, `randint(3, 5)` and that many `choice(words)`, joined by spaces; then every head before a comma with every two-word pair behind it, and `Smith, ` and ` , John` for every word; deduplicated, under four policies (default, both family-first orders, strict comma suffixes) -- 81,138 names, 324,552 parses, comparing the seven roles, every ambiguity's kind and detail, `initials()` and `capitalized()`. Fingerprint: 6 parses move, `John van der Berg Prof.` under six policies, and nothing else. Connective grid: 1,248 move, no corpus or case name among them: 1,212 the title class, and 36 garbage shapes holding a title or a credential run behind a leading particle (`de der Dr. MA PhD Vega y Lopez` reads suffix 'PhD Vega y Lopez', #602's run, where 0c54662b kept every word the family), 8 of them moving a report's grouping alone (below). Stress grid: 8,472, no corpus or case name; 48 move reports alone, 20 the suffix wording above and 28 the grouping of an absorbed word's report where the run the view stops unit-making at is not the one the peel reads (`anh van Do Sr DO Jones MBA` reports 'DO' and 'Jones' where 0c54662b reported 'DO Jones'); restricted to a head and one tail word under the default policy, 12 parses, the six particle-chain heads with `Dr.` and `Prof.`, all the title class. (At bfa798fb, before the second review, these were 30, 2,936 and 20,532, the slice 33.) Whole tail readings per corpus name (`tail_reading` calls, 1,536 names): 1,125 read once, 56 twice, none three times, against 1,095, 55 and 30 on 0c54662b; peel passes, which count H5's fixed point's own re-peels, 1,215 names at one against 583. Five differential gates exit 0 with the one move classified `fix(#614)` at every baseline. - COST, `tools/perf/call_count.py` on each interpreter against adf6da88 (#619's merge): `parse` 317 -> 308 on 3.11 and 296 -> 290 on 3.12-3.15, `facade` 354 -> 345 and 333 -> 327 (decisions/parse-cost, this date). Per name on 3.11, warm: the Vega name 376 -> 368, and every segment of fewer than three pieces unchanged, never reaching the read (`John Smith` 152, `Smith, John` 161, `John Smith, PhD` 206). -- 2026-10-08 (Derek), #620 — S2'S READ COUNTS THE CHAIN'S UNITS OVER THE PIECES AS WRITTEN, RATHER THAN MERGING A COPY OF THEM. The #614 read above counted through a merged copy of the pieces, a second statement of P2's chain with its own stop list, and its reviews found three defects that were that copy's: an index applied across the two layouts (`Freiherr St van Berg MA`), a unit formed inside #602's run hiding a title (`John Smith MD van Secretary Jones`), and a shape-only numeral folded into a unit (`Juan de la Vega VI`), each patched in the copy. Now `_pieces._chain_units` marks each piece by the unit the chain will put it in -- OPENS, JOINED (a name word the chain joins to the run in front of it) or BOUND (a particle inside the run, a word the run took; retired by #625, this section, 2026-10-10, group merging the run first) -- asking `chain_run_end`, the chain's run end, which `_group`'s chain now calls too. The peel's words to spare (`second_unit`) and #602's run start count in those marks and the peel stops at any piece the chain joins into a run, which is a name word whichever mark it carries; everything else reads the pieces as written, so nothing is hidden, and the copy, its view index and its credential-run gate are gone. - WHAT THE COUNT KEEPS. A word the reading weighs -- a suffix word of either kind, the ambiguous class, a word in it by shape, an initial, a roman numeral by shape, a period-marked title word (`_weighed`) -- keeps a unit of its own though the chain may join it, as a position always counted it; only the plain words behind a particle run are JOINED, and a unit with nothing past its opener moves no name start into the titles (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: no unit opens inside the titles, so the gate is gone). The first prototype marked every word the chain reaches JOINED, by the chain's own rule alone, and the review found it wrong in two shapes the earlier count read right: `Freiherr von Berg MA X.Y.Z.` kept 'MA X.Y.Z.' in the family (a member the chain would join counted as no word in front of the next), and `St St VI` and `Freiherr St MA` read title 'St St', 'Freiherr St' with the numeral or acronym the whole family (a unit of words the read then takes, which the chain never builds). Both masters read all three as they read now. (#624 since reads `St St VI` and `Freiherr St MA` as title 'St St' / 'Freiherr St' again, by a different route: the chain and the count now share one leading position, the last title-particle, so neither builds the unit -- decisions/P2, 2026-10-10.) The copy's exclusion list had been carrying the count positions always kept -- a member in front counts -- and the marks keep it without the copy. The numeral fork still reads the piece in front of the numeral as written -- reading the unit's first word broke rules/P2's `John van der J. V` -- so `John van B and Smith X` reads family 'van B and Smith X' again, the reading #614's copy had moved. - ALSO COUNTED IN UNITS: #602's run start, a particle opening a chain run counting once and its bound particle not at all (`John der la Jr. Prof. Smith` keeps its run, `van la Smith Secretary Jr. Dr. Smith` starts none, as on master). And `credential_run`, on the read's path, gathers adjacent particles into the one absorbed piece the chain makes of them (`Dr. John Smith Esq. RN Mc Mc` reports 'Mc Mc'; SUPERSEDED by #625, this section, 2026-10-10: group merges the run before the read, which absorbs it as one piece); M2's clause-free view reports word by word as it always has. - DECLINED, measured: a units test in `tail_reading`'s resume condition. A splice lowers a count only by taking out titles that open units of their own, and with fewer than two units in front of it every piece there is in the first piece's chain run, which the titles behind it join; fuzzed over 372,330 names, the test changed nothing. - CLASSIFICATION, found writing this entry's rows: eight case rows #614 added as `parity` did not match 1.4.0 -- they had been compared against master -- and are relabelled with the change each reading came from, bisected (#424, #289, #296, #516, #602 twice; R2 and P2 for two that read so since the 2.0 pipeline). - MEASURED 2026-10-08 against 4278693d (#621's merge), py3.11, `nameparser.__file__` asserted, with the #614 harnesses and recipe above: fingerprint 0 moves; connective grid 0; S2 stress grid 12, three garbage names under four policies, none in the head-plus-one-word slice -- `Freiherr von Berg Dr. X.Y.Z. VD` reads title 'Freiherr Dr.', suffix 'X.Y.Z. VD' (H5's trailing title, the class #614 moved; master family 'von Berg Dr. X.Y.Z.'), `Jan Freiherr von Berg VD V and ba I` reads family 'VD V and ba I' as adf6da88 did (master suffix 'I'), and `de Mesnil Ma do mc and Ph. D.` reports the 'do' the peel took beside adf6da88's 'Ma'. Five gates exit 0. Frames unchanged, 308 on 3.11 and 289 on 3.12-3.15 (decisions/parse-cost). - /SIMPLIFY (2026-10-08), measured. Each pass of `tail_reading` had rescanned for the second unit from the front, a C-level quadratic no frame guard saw: `Freiherr von` + `Berg `*n + `MA Dr. `*n read 5.77x for 4x the input against master's 4.08x; the lookup is now made once per read and handed to every pass (4.03x), and `test_the_second_unit_is_found_once_per_read` counts it. `_weighed` is pinned to the reading by `test_a_word_the_read_takes_is_never_one_the_count_joined` (no piece marked JOINED is ever in the run the read takes; 320 grid names fail with the test answering False). The gathering of an absorbed particle run reads the BOUND marks, and #602's run start one condition. Grids byte-identical, frames unchanged. Left for a follow-up, each moving readings: merging the tail's particle runs in group before the read, which would retire the BOUND stop and the gathering (80 grid names move, `Dr. mc mc`, and the chain's particle report needs re-keying; done by #625, this section, 2026-10-10, where #624 had already moved those 80); and the name start decided once, the item below. - LEFT OPEN, by Derek's scoping: reading a family comma's given part the same way, and deciding the name's start once for a title-particle head (#620's related paths, which change readings). The second was settled by #624 (decisions/P2, 2026-10-10), the first by #623 with a narrower bound rather than a second read (decisions/H5, 2026-10-10). -- 2026-10-10 (Derek), #627 — A LONE PARTICLE WITH THE RUN RIGHT BEHIND IT COUNTS AS A NAME WORD. #602's run start needs two name words before the credential, and "a lone particle is not a name word for the count", for `de Mesnil`, where the particle and the word behind it are one surname. Since #620 the count is taken in the chain's units, so a particle that opens a unit counts once (`van der`, `von Berg`) and a P3-joined phrase is one piece (`von und zu`); the lone particle stayed uncounted only where nothing joins it, which is exactly where a credential stands behind it. So `John von PhD Jones` read middle 'von PhD', family 'Jones' -- the chain later taking the credential as the particle's surname -- while `John van der PhD Jones` and `John von und zu PhD Jones` read family 'van der' / 'von und zu', suffix 'PhD Jones', and the comma spelling `Smith, John von PhD Jones` already read suffix 'PhD Jones'. The issue's question was which way to make them agree. - DECIDED (Derek): the particle stops at the credential, so it is a name word of its own and the rest is suffix: `John von PhD Jones` → family 'von', suffix 'PhD Jones'. The input is malformed (a particle is not written before a post-nominal), so the decision is whichever reading the shared count already gives the other forms, not a special case for this one: the existing clause gains its own limit, a lone particle counting when the run starts right behind it, in `run_start`'s one loop. The caller without the chain's units (`trailing_start`) shares the expression so the two cannot disagree, but no reading shows that half: it changes the position it returns for `Smith, John von PhD Jones` (2 where master gave 4), so group's chain takes its stopped path and asks `trailing_start` again, reaching the same reading at 16 more frames (339 → 355 on py3.11; a rare shape no frame guard sees, decided for the shared expression over a per-caller copy); a mutant ignoring the flag there passed the suite with the grid byte-identical (#627's reviews). The 1.4.0 and 2.3.0 wheels read middle 'von PhD', as they read `von und zu PhD`; the joined forms changed this cycle with #602. - Declined: counting every unit, a lone particle included. It moved 2,836 S2-grid parses rather than 2,432, the extra ones all `de Mesnil `, where it counted the surname as two words, and it split the two callers: a leading `van` counted before another particle (`van la Smith Jr. Jones` → suffix 'Jr. Jones') but not before a name word (`van Smith Jr. Jones`), and the case row `van la Smith Secretary Jr. Dr. Smith` caught it. - MEASURED 2026-10-10 against 195c4081 (#630's merge), py3.11, `nameparser.__file__` asserted on each side. S2 stress grid (the #614 entry's recipe above, 324,552 parses): 2,432 role moves, no report-only move; every move is a lone particle -- in that grid `van`, or a dual `do`/`mc`/`vd` in any case -- with an unambiguous credential right behind it becoming the family (under the family-first orders the given name, the family staying first), the run taking the rest. The grid's comma shapes do not move; the review found the same move in shapes the grid lacks, as intended: other particles (`von`, `de`, `du`, `la`, `bin`), and a head before a suffix comma (`John von PhD Jones, Jr.` → family 'von', suffix 'PhD Jones, Jr.'). Fingerprint of every `tools/differential/corpus*.jsonl` name and `tests/v2/cases.py` text at 195c4081 under the three orders (1,925 texts, the seven fields and every report): 0 moves; this change adds one text, its own example, which moves. The five differential gates exit 0. Held by the case row `John von PhD Jones` (`fix(#627)`) against the row `van la Smith Secretary Jr. Dr. Smith`, whose lone particle has a name word behind it and still does not count, and the row `Freiherr von vd PhD Jones`, where the particle standing right before the credential is one the run took and the flag is not asked of it (a mutant asking it of every piece passed the suite until that row). -- 2026-10-10 (Derek), #625 — THE TAIL'S PARTICLE RUNS ARE MERGED IN GROUP BEFORE S2'S READ, SO THE READ NEEDS NO BOUND MARK AND NO GATHERING. Since #614 split the trailing run off ahead of the chain, the read sees particles the chain has not merged yet, and #620 re-created the chain's particle-to-particle merge in the reader: `_chain_units` marked a particle standing straight behind another BOUND, the peel stopped at it, and `credential_run` gathered an absorbed run of them into one report. Now group merges each run of two or more adjacent particles past the chain's leading position (`_pieces.merge_particle_runs`, at the trailing site, after P3's joins and before the read) into the piece the chain would open its run with, so the read counts, stops at and absorbs the run as one piece, and the BOUND mark, the peel's stop for it and the gathering are gone. The merged piece keeps `prefix` and drops `title`: a P3 join carries the title tag in from a word in both vocabularies (`St und Berg`), and on the merged run it read the run as a leading title. The title run and the lead are read once, before the merge, and handed to the read, which had asked both again. The chain's PARTICLE_OR_GIVEN report keyed on its run claiming something past the particle (`j > k + 1`), which a merged run has done already though the chain then claims nothing more (`Freiherr von der`); it now keys on that or on the run being one the merge made (`premerged`) -- not on the piece holding several tokens, which a P3-joined phrase does too, and which moved 11,358 reports on the grid below (`Dr. St Do und`). The rule text is unchanged: rules/S2 already counts a particle run, the duals straight behind a particle included, as the one name word the chain makes. What grows is code, not concepts: the read loses a mark and two branches and re-creates nothing of the chain, group gains the merge (about 45 more lines than it removes across `_pieces.py` and `_group.py`, docstrings included). - DECIDED (Derek): retiring the two reader-side mechanisms is worth the moves -- every one is garbage input, now read consistently, and none is a released reading restored. - MEASURED 2026-10-10 against 505d6dec (#632's merge), py3.11, `nameparser.__file__` asserted on each side. A particle-run grid -- `{head} {run} {tail}` for every head in (none), `Dr.`, `Freiherr`, `St`, `John`, `Dr. John`, `Freiherr von`, `John Smith`, `Sir`, `Freiherr St`; every run of one to three words over `van der de la von und mc Mc vd do Do St du`; every tail in (none), `Berg`, `MA`, `PhD`, `Ma`, `Jr.`, `Berg MA`, `Prof.`, `Berg Ph. D.`, `V`, `VI`, `X.Y.Z.`, `née Smith`, `Berg Jr. PhD`, `Berg van`, `, Jr.`, `, John` (a comma tail written against the run) -- 395,148 texts, 1,185,444 parses under the three orders, comparing the seven fields and every report (kind, detail, tokens): 774 role moves (258 texts under each order), no report-only move. Every move holds `St und`, the joined piece carrying the title tag, which master merged into the particle run in front with the tag kept and read as a title (`Freiherr Do St und Berg MA` → title 'Freiherr Do St und Berg', family 'MA'); now title 'Freiherr', family 'Do St und Berg', suffix 'MA', the name part read alike with and without the trailing 'MA'. Not a released reading: 1.4.0 reads first 'Do St und Berg', last 'MA', and 2.3.0 family 'Do St und Berg MA', and over the 258 moved texts under the default order master's seven fields equal 2.3.0's on 152, the tree's on none. The S2 stress grid (#614's recipe, above): no role move, and one text's report under its four policies -- `de Mesnil Ma do mc and Ph. D.` no longer reports 'do': P3 joins `mc and Ph. D.` into one suffix-tagged piece, the merge folds 'do' into it, and the peel takes the piece whole rather than picking 'do', the report adf6da88 did not make either and #620 added (its entry above). The #624 title-particle head grid (decisions/P2, 2026-10-10): 0. Every corpus name and case text, and #624's comma-tail grid, under the three orders: 0. The five differential gates exit 0, their reports identical line for line to the tree before. The 2026-10-08 prototype's 80 moves (`Dr. mc mc`) are not in the population: #624 already reads `Dr. Mc Mc` as H3 states. Recorded negative controls (2026-10-10): with the merge off, 30 tests in tests/v2 fail (32 over the whole suite, measured at the review's fix), `test_an_absorbed_particle_run_reports_as_the_one_word_the_chain_makes` among them; with the report keyed on `j > k + 1` alone, the case rows `a_particle_takes_the_dual_behind_it_at_the_head` and `a_particle_run_before_the_run_stays_one_name_word`; with the merge starting at the segment's second piece rather than past the lead, 13; with `title` kept, only `test_a_merged_particle_run_is_a_particle_and_no_title`, the unit test added for it. Cost: +3 frames on the reference row, which holds `de la` (decisions/parse-cost, this date). - THE REVIEW (2026-10-10) found the change reaching further, every shape garbage, none given a row (Derek): no move goes the other way, and nothing moved over comma shapes (an empty or nickname-only head, a family comma's given part), connective-separated runs, runs beside a maiden marker or holding a nickname, bound given names, one-case names, or caller lexicons adding title or suffix words as particles. (1) The `St und` class is any P3 join whose left word is in both the title and particle vocabularies, `und`, `and` or `of` (`y` moves nothing: master already reads `Freiherr Do St y Berg MA` as now), a caller's title-particle included (`Freiherr Do Freiherr and Berg MA`: master title 'Freiherr Do Freiherr and Berg', family 'MA'; now title 'Freiherr', family 'Do Freiherr and Berg', suffix 'MA'). (2) A title-particle head, a particle run, then a title and a dotted word (`Freiherr van der Dr. G.J. MA`, 88 texts on its grid under each order): the run now counts as one name word, so 'G.J.' has none to spare, the read takes nothing and group's own chain takes 'Dr. G.J.' -- family 'van der Dr. G.J.', suffix 'MA', as the one-particle `Freiherr van Dr. G.J. MA` reads (master title 'Freiherr Dr.', family 'van der', suffix 'G.J. MA'). (3) H3's give-back behind a leading title where every word is suffix vocabulary, the last two a run of duals (`Dr. vd Jr. mc mc`, about 1,800 parses over two grids): the merged run is a particle piece and no suffix word, so the title stays -- title 'Dr.', family 'vd', suffix 'Jr. mc mc', where master gave 'Dr.' back as the given name with 'vd' still standing; `Dr. vd Jr. mc` still gives it back. The review also found two runs in one name unreached by any test (an off-by-one in the merge's bookkeeping passed the suite), now the rows `two_particle_runs_merge_each` and `a_second_run_of_duals_is_the_family` (parity) and a half of the merge's unit test, which also holds that a lone particle is no merged run; and the read's title run taken before the merge held only by the frame bands, now `test_the_read_takes_the_title_run_as_written_before_the_merge` (`Dr. mc Jr mc mc`, which with the run asked again after the merge, by the read or by group, reads family 'mc Jr mc mc'; the fix's own review found the group-side half unheld by a read-level test, so it pins the parse too). -- 2026-10-14 (Derek), #679 — ANY UNAMBIGUOUS SUFFIX WORD OF TWO OR MORE LETTERS STARTS THE RUN, WHATEVER ITS SCRIPT; THE HONORIFIC EXCEPTION IS RETRACTED. #602 excluded "the NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one", and drew that line by script, the vocabulary having no honorific marker. #661 then added Cyrillic and Devanagari GENERATIONAL words the script line refused too, splitting Serbian by the script it is written in (#generational-words). Measured 2026-10-14 over every non-Latin suffix word (51) in six shapes under three orders (918 parses), the exception protected no correct reading: an honorific followed by a name word read as a MIDDLE name, in silence (`राम शर्मा साहब मोहन` gave middle 'शर्मा साहब', family 'मोहन'; `משה כהן ז"ל דוד` middle 'כהן ז"ל'), and dropping it reads the honorific as the suffix it is with the word behind it absorbed and reported, as `John Smith PhD Jones` reads. 360 of the 576 honorific parses move and 285 of the 342 generational ones, the same 120 and 95 under each order; the one-character honorifics (씨 様 氏 殿 군 님 양, and जी, whose vowel sign is no letter) never move, S2's two-letter minimum keeping them out already. No realistic name with a name word after an honorific was found, and no corpus name outside rules.md's own examples moves; of the three S2 examples that had pinned the refusal, two now pin the run, and the mixed-script `Taro Tanaka さん Jones` gives way to `राम शर्मा साहब मोहन`. Recompute: the words are master's `Lexicon.default().suffix_words` (after #681) whose letters are not all LATIN by Unicode name; each goes between same-script name words in the shapes `{a} {b} {w} {c}`, `{b}, {a} {w} {c}`, `{a} {b}, {w} {c}`, `{a} {b} {w} {c} {c}`, `{a} {w} {c}` and `{b} {a} {w} {c}`, under each of the three orders, keyed by the order's NAME (the first count keyed by its first role and lost an order, two orders both beginning with FAMILY); compare `as_dict()` and the ambiguity kinds with `_pieces.starts_a_credential_run`'s script test in and out. Generational words are not a class of their own here: a suffix word is a suffix word, which is what drops `_pieces._all_latin` (#681, the same day) and the need for any honorific or generational marker as #679's roster adds non-Latin words. - diff --git a/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md b/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md new file mode 100644 index 00000000..8a963fc9 --- /dev/null +++ b/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md @@ -0,0 +1,25 @@ +# S2 — a suffix word ends a name and never opens the string + +- 2026-10-07 (Derek), #614 — S2'S TRAILING RUN IS READ ONCE, BETWEEN P3'S CONNECTIVE JOINS AND THE PARTICLE CHAIN, AND BINDS. The issue's diagnosis held on the day: the words-to-spare count is taken over pieces and group's joins make pieces, so group re-asked the peel before and after its own joins -- the particle chain's stop and its re-ask and rollback, the bound-given reserve's two views (P5) -- and assign then read the run again. Now the run of a main segment (no comma, or the part before a suffix comma) of three or more pieces is read once, after the Ph. D. merge, the maiden take and P3's connective joins, which do not depend on the run and make the units it counts, and before the particle chain and the bound-given join, which do; the run is split off, the chain and the join group the name in front of it, and the run is put back unchanged (`_pieces.read_trailing_run`). The chain's stop is the end of what is left and the reserve counts the name pieces the read left; assign places and reports the run off the read (`ParseState.tail_reads`, `_assign._placed`). The read COUNTS a particle run and the plain name words after it as the one unit the chain will make of them (`_counting_view`, a copy; rules/P3's own count, mechanisms.md#UNIT-PARTITION), the words both particles and suffix vocabulary straight behind a particle among them, while anything the read weighs stays a word of its own -- suffix vocabulary, the ambiguous class and its shape-only members, roman numerals by shape, initials, period-marked titles. + STALE IN THE ISSUE, measured before designing: segment no longer decides the comma or emits S2 reports (#613 moved C1 to `_comma.decide`); assign predicts P1's fold but no longer P6, which it decides since #613; the two comma restart inputs (`John Smith, PhD vd DO`, `PhD DO DO`) no longer reach the chain, decide binding that part first; `credential_run` already applied in one place; and #613's `name_word_count` reads particle and suffix facts at P1's reach and ignores connective and bound-given joins, so S2 could not share it as it stood. The issue's 'licensed by neighbour', read literally, moves about 25 inputs (`JOHN SMITH MA`, six S3 example lines) and still five read as scoped to the duals, so it was not taken; the counting view's particle run is the one part of it the design needed. + THE FIRST DESIGN AND THE REVIEW THAT REVERSED IT. Derek decided first that the read WINS OVER EVERY JOIN, connectives included -- prototyped both ways in a scratch copy -- and that implementation passed the suite, the gates and the fingerprint. #614's PR review then found it broke what the corpus never tests: a salutation alone read its connective as the family name (`Mr. and Mrs.` gave title 'Mr. Mrs.', family 'and' -- the H5 chain took 'Mrs.' before P3 could join), a joined leading salutation counted as three words (`Mr. and Mrs. Jr` gave family 'and', `Mr. and Mrs. Do` a spurious report), and the read counted a particle chain's or a connective join's words one by one where rules/P3 says a joined part is one name word wherever another rule counts (`Freiherr von Berg X.Y.Z.` and `Juan y Garcia X.Y.Z.` took the acronym as a credential against every release, while `Freiherr Berg X.Y.Z.` kept it the family). DECIDED (Derek, 2026-10-07): connectives join first, and the read counts the chain's units -- which returns every one of those to master's reading, and most of the first design's 'tail beats connectives' class with them. A second-round finding the counting view itself made: folding the plain words after a particle into its unit swallowed a shape-only roman numeral (`Juan de la Vega VI` read family 'de la Vega VI'), the #610 class AGENTS.md's spelling sweep records, so a word the numeral fork reads by shape stays out of the unit. + DECIDED BEFORE THE REVERSAL AND STANDING: (1) the read wins over the particle chain and the bound-given join; (2) the consequences below, accepted as a class. + WHAT MOVES. One realistic reading: a trailing period-marked title behind a particle chain is the title, `John van der Berg Prof.` reading title 'Prof.', family 'van der Berg' (every release 1.4.0-2.3.0 and 0c54662b: family 'van der Berg Prof.'), which retires the cost rules/H5 recorded as accepted; the six heads of the stress grid below that end in a particle chain move the same way with `Dr.` or `Prof.` behind them. The garbage `Freiherr von Berg Dr. and Ed. Prof.` reads title 'Freiherr von Berg Dr. and Ed.', family 'Prof.' (0c54662b family 'von Berg Dr. and Ed. Prof.'): the title is read off before the chain, which then joins a piece carrying the joined title and reads as a leading title, so assign reads the name again. P5 no longer re-reads the joined view: `abdul J. V` and `Sir abdul J. V` read given 'abdul J.', family 'V' -- 1.4.0's reading restored; 2.0 and 2.1 read suffix 'V', 2.2 and 2.3 kept 'J.' a middle name -- the initial in front suppressing the numeral fork in the one reading taken, three name words standing and the join leaving two; and `Sheik abdul Ma` reports the pick the read weighed (decisions/P5, this date). Reports: a pick the particle chain takes into the name is reported by the chain, in its own words, and group takes it out of the read's picks so assign does not report it again (#614's /simplify: assign first skipped a pick it found already reported, inferring group's decision from group's output), so `Jan vd Ma`, `John de Ma`, `John van der Berg Ma` and `Nguyen Van Lac` report as on 0c54662b. The second review found why it must be the chain: assign words a pick by the role it holds before post_rules, and under a leading title-particle that is the given role the fold then moves, so with assign reporting `Freiherr von Berg Ma` told the caller 'read as a given name' of a word in the family. The chain also reports a member the read never weighed, one it takes short of the run, its scan ending at the next particle (`Freiherr von Berg Ed Do` reports 'Ed'). One report moves for the better: a pick the run keeps is reported as the suffix it reads as, `Juan y Garcia Mc Do MD van Ed` reporting 'Ed' 'read as a suffix' where 0c54662b said the particle chain took it into the name, of a word in the suffix. + THE SECOND REVIEW (bfa798fb, 2026-10-07) found four defects in the counting view and the report hand-off, every one on a shape the corpus, the gates and the first stress grid held no instance of, and each now has a case row that dies without its fix. (1) The read started at the end of the leading titles counted in PIECES, applied to the view: a title that is also a particle opens a unit inside the titles, so the index landed past the name and `Freiherr St van Berg MA` read family 'St van Berg MA' (0c54662b suffix 'MA'); the view now returns where the name starts in its own indices -- the first unit opened at or before the titles' end, since a unit opened inside them makes a name of them (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: the chain now opens no unit inside the titles) even where it ends short (`Freiherr Freiherr Prof do` lost its report on 'do' until that second half). (2) Assign reported the chain's picks, in the wrong field's words, above. (3) The view made units INSIDE #602's credential run, which hid a title word from the run's title test: `John Smith MD van Secretary Jones` lost title 'Secretary' to the suffix. Past a run starter a unit is now the bare particle run, as the chain's own merge leaves it. (4) A `title` piece-tag exclusion in `_counted_as_one` made garbage differ from 0c54662b (`Freiherr von Mr. and Mrs. X.Y.Z.`) and guarded nothing (3) did not; removed rather than pinned. + NOT THE SAME READ AS M2'S: the maiden take reads its own clause-free view of the words before any join, word by word, as rules/M2 states, and its Accepted clause already covers what that does where a particle chain would take the run; S2's read counts the chain's units after P3's joins. Two reads of one name answer different questions -- where the clause ends, where the name's run starts -- and are kept apart rather than made one here. + RESIDUALS, the paths that read as before #614: a segment of fewer than three pieces (no join reaches it); a run with a name piece inside it (an H5 title in front of a name word the peel declined, `John Dr. G.J.`, `John van V Dr. V`), which is not split -- group re-asks and assign reads for itself; and a name group's joins leave without a name word in front of the run, which assign reads again so it keeps one -- the same procedure as before #614, not always the same answer, the Freiherr garbage above being the measured case. + MEASURED 2026-10-07 against 0c54662b (#618's merge), py3.11, `nameparser.__file__` asserted on each side; #619, merged between, moved no reading. The two #617 harnesses as decisions/P3's 2026-10-06 entry records them (the fingerprint, 78,324 parses; the connective grid, 485,300), and an S2 stress grid built for this entry: the 22 heads `John`, `John Smith`, `John Q. Smith`, `Mary Ann Smith`, `John van Smith`, `Juan de la Vega`, `John van`, `anh van`, `Jan Freiherr von Berg`, `Freiherr von Berg`, `abdul salam`, `abdul rahman al-said`, `Juan y Garcia`, `Josep Carod i Rovira`, `Jane Doe nee van der Berg`, `Dr. John Smith`, `JOHN SMITH`, `john smith`, `Smith`, `de Mesnil`, `Kim Min Jun`, `Ortega y Gasset`, each followed by every one- and two-word tail over the 35 words `PhD MA Ma DO Do do vd VD mc Mc MD Jr Jr. Sr III V V. VI I X.Y.Z. XYZ Ph. D. Esq. Prof. Dr. Sir MBA RN G.J. Ed van and Jones Ed. ba` (`Ph. D.` one word of the list); then 25,000 draws seeded `random.seed(614)`, each `choice(heads)`, `randint(3, 5)` and that many `choice(words)`, joined by spaces; then every head before a comma with every two-word pair behind it, and `Smith, ` and ` , John` for every word; deduplicated, under four policies (default, both family-first orders, strict comma suffixes) -- 81,138 names, 324,552 parses, comparing the seven roles, every ambiguity's kind and detail, `initials()` and `capitalized()`. Fingerprint: 6 parses move, `John van der Berg Prof.` under six policies, and nothing else. Connective grid: 1,248 move, no corpus or case name among them: 1,212 the title class, and 36 garbage shapes holding a title or a credential run behind a leading particle (`de der Dr. MA PhD Vega y Lopez` reads suffix 'PhD Vega y Lopez', #602's run, where 0c54662b kept every word the family), 8 of them moving a report's grouping alone (below). Stress grid: 8,472, no corpus or case name; 48 move reports alone, 20 the suffix wording above and 28 the grouping of an absorbed word's report where the run the view stops unit-making at is not the one the peel reads (`anh van Do Sr DO Jones MBA` reports 'DO' and 'Jones' where 0c54662b reported 'DO Jones'); restricted to a head and one tail word under the default policy, 12 parses, the six particle-chain heads with `Dr.` and `Prof.`, all the title class. (At bfa798fb, before the second review, these were 30, 2,936 and 20,532, the slice 33.) Whole tail readings per corpus name (`tail_reading` calls, 1,536 names): 1,125 read once, 56 twice, none three times, against 1,095, 55 and 30 on 0c54662b; peel passes, which count H5's fixed point's own re-peels, 1,215 names at one against 583. Five differential gates exit 0 with the one move classified `fix(#614)` at every baseline. + COST, `tools/perf/call_count.py` on each interpreter against adf6da88 (#619's merge): `parse` 317 -> 308 on 3.11 and 296 -> 290 on 3.12-3.15, `facade` 354 -> 345 and 333 -> 327 (decisions/parse-cost, this date). Per name on 3.11, warm: the Vega name 376 -> 368, and every segment of fewer than three pieces unchanged, never reaching the read (`John Smith` 152, `Smith, John` 161, `John Smith, PhD` 206). +- 2026-10-08 (Derek), #620 — S2'S READ COUNTS THE CHAIN'S UNITS OVER THE PIECES AS WRITTEN, RATHER THAN MERGING A COPY OF THEM. The #614 read above counted through a merged copy of the pieces, a second statement of P2's chain with its own stop list, and its reviews found three defects that were that copy's: an index applied across the two layouts (`Freiherr St van Berg MA`), a unit formed inside #602's run hiding a title (`John Smith MD van Secretary Jones`), and a shape-only numeral folded into a unit (`Juan de la Vega VI`), each patched in the copy. Now `_pieces._chain_units` marks each piece by the unit the chain will put it in -- OPENS, JOINED (a name word the chain joins to the run in front of it) or BOUND (a particle inside the run, a word the run took; retired by #625, this section, 2026-10-10, group merging the run first) -- asking `chain_run_end`, the chain's run end, which `_group`'s chain now calls too. The peel's words to spare (`second_unit`) and #602's run start count in those marks and the peel stops at any piece the chain joins into a run, which is a name word whichever mark it carries; everything else reads the pieces as written, so nothing is hidden, and the copy, its view index and its credential-run gate are gone. + WHAT THE COUNT KEEPS. A word the reading weighs -- a suffix word of either kind, the ambiguous class, a word in it by shape, an initial, a roman numeral by shape, a period-marked title word (`_weighed`) -- keeps a unit of its own though the chain may join it, as a position always counted it; only the plain words behind a particle run are JOINED, and a unit with nothing past its opener moves no name start into the titles (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: no unit opens inside the titles, so the gate is gone). The first prototype marked every word the chain reaches JOINED, by the chain's own rule alone, and the review found it wrong in two shapes the earlier count read right: `Freiherr von Berg MA X.Y.Z.` kept 'MA X.Y.Z.' in the family (a member the chain would join counted as no word in front of the next), and `St St VI` and `Freiherr St MA` read title 'St St', 'Freiherr St' with the numeral or acronym the whole family (a unit of words the read then takes, which the chain never builds). Both masters read all three as they read now. (#624 since reads `St St VI` and `Freiherr St MA` as title 'St St' / 'Freiherr St' again, by a different route: the chain and the count now share one leading position, the last title-particle, so neither builds the unit -- decisions/P2, 2026-10-10.) The copy's exclusion list had been carrying the count positions always kept -- a member in front counts -- and the marks keep it without the copy. The numeral fork still reads the piece in front of the numeral as written -- reading the unit's first word broke rules/P2's `John van der J. V` -- so `John van B and Smith X` reads family 'van B and Smith X' again, the reading #614's copy had moved. + ALSO COUNTED IN UNITS: #602's run start, a particle opening a chain run counting once and its bound particle not at all (`John der la Jr. Prof. Smith` keeps its run, `van la Smith Secretary Jr. Dr. Smith` starts none, as on master). And `credential_run`, on the read's path, gathers adjacent particles into the one absorbed piece the chain makes of them (`Dr. John Smith Esq. RN Mc Mc` reports 'Mc Mc'; SUPERSEDED by #625, this section, 2026-10-10: group merges the run before the read, which absorbs it as one piece); M2's clause-free view reports word by word as it always has. + DECLINED, measured: a units test in `tail_reading`'s resume condition. A splice lowers a count only by taking out titles that open units of their own, and with fewer than two units in front of it every piece there is in the first piece's chain run, which the titles behind it join; fuzzed over 372,330 names, the test changed nothing. + CLASSIFICATION, found writing this entry's rows: eight case rows #614 added as `parity` did not match 1.4.0 -- they had been compared against master -- and are relabelled with the change each reading came from, bisected (#424, #289, #296, #516, #602 twice; R2 and P2 for two that read so since the 2.0 pipeline). + MEASURED 2026-10-08 against 4278693d (#621's merge), py3.11, `nameparser.__file__` asserted, with the #614 harnesses and recipe above: fingerprint 0 moves; connective grid 0; S2 stress grid 12, three garbage names under four policies, none in the head-plus-one-word slice -- `Freiherr von Berg Dr. X.Y.Z. VD` reads title 'Freiherr Dr.', suffix 'X.Y.Z. VD' (H5's trailing title, the class #614 moved; master family 'von Berg Dr. X.Y.Z.'), `Jan Freiherr von Berg VD V and ba I` reads family 'VD V and ba I' as adf6da88 did (master suffix 'I'), and `de Mesnil Ma do mc and Ph. D.` reports the 'do' the peel took beside adf6da88's 'Ma'. Five gates exit 0. Frames unchanged, 308 on 3.11 and 289 on 3.12-3.15 (decisions/parse-cost). + /SIMPLIFY (2026-10-08), measured. Each pass of `tail_reading` had rescanned for the second unit from the front, a C-level quadratic no frame guard saw: `Freiherr von` + `Berg `*n + `MA Dr. `*n read 5.77x for 4x the input against master's 4.08x; the lookup is now made once per read and handed to every pass (4.03x), and `test_the_second_unit_is_found_once_per_read` counts it. `_weighed` is pinned to the reading by `test_a_word_the_read_takes_is_never_one_the_count_joined` (no piece marked JOINED is ever in the run the read takes; 320 grid names fail with the test answering False). The gathering of an absorbed particle run reads the BOUND marks, and #602's run start one condition. Grids byte-identical, frames unchanged. Left for a follow-up, each moving readings: merging the tail's particle runs in group before the read, which would retire the BOUND stop and the gathering (80 grid names move, `Dr. mc mc`, and the chain's particle report needs re-keying; done by #625, this section, 2026-10-10, where #624 had already moved those 80); and the name start decided once, the item below. + LEFT OPEN, by Derek's scoping: reading a family comma's given part the same way, and deciding the name's start once for a title-particle head (#620's related paths, which change readings). The second was settled by #624 (decisions/P2, 2026-10-10), the first by #623 with a narrower bound rather than a second read (decisions/H5, 2026-10-10). +- 2026-10-10 (Derek), #625 — THE TAIL'S PARTICLE RUNS ARE MERGED IN GROUP BEFORE S2'S READ, SO THE READ NEEDS NO BOUND MARK AND NO GATHERING. Since #614 split the trailing run off ahead of the chain, the read sees particles the chain has not merged yet, and #620 re-created the chain's particle-to-particle merge in the reader: `_chain_units` marked a particle standing straight behind another BOUND, the peel stopped at it, and `credential_run` gathered an absorbed run of them into one report. Now group merges each run of two or more adjacent particles past the chain's leading position (`_pieces.merge_particle_runs`, at the trailing site, after P3's joins and before the read) into the piece the chain would open its run with, so the read counts, stops at and absorbs the run as one piece, and the BOUND mark, the peel's stop for it and the gathering are gone. The merged piece keeps `prefix` and drops `title`: a P3 join carries the title tag in from a word in both vocabularies (`St und Berg`), and on the merged run it read the run as a leading title. The title run and the lead are read once, before the merge, and handed to the read, which had asked both again. The chain's PARTICLE_OR_GIVEN report keyed on its run claiming something past the particle (`j > k + 1`), which a merged run has done already though the chain then claims nothing more (`Freiherr von der`); it now keys on that or on the run being one the merge made (`premerged`) -- not on the piece holding several tokens, which a P3-joined phrase does too, and which moved 11,358 reports on the grid below (`Dr. St Do und`). The rule text is unchanged: rules/S2 already counts a particle run, the duals straight behind a particle included, as the one name word the chain makes. What grows is code, not concepts: the read loses a mark and two branches and re-creates nothing of the chain, group gains the merge (about 45 more lines than it removes across `_pieces.py` and `_group.py`, docstrings included). + DECIDED (Derek): retiring the two reader-side mechanisms is worth the moves -- every one is garbage input, now read consistently, and none is a released reading restored. + MEASURED 2026-10-10 against 505d6dec (#632's merge), py3.11, `nameparser.__file__` asserted on each side. A particle-run grid -- `{head} {run} {tail}` for every head in (none), `Dr.`, `Freiherr`, `St`, `John`, `Dr. John`, `Freiherr von`, `John Smith`, `Sir`, `Freiherr St`; every run of one to three words over `van der de la von und mc Mc vd do Do St du`; every tail in (none), `Berg`, `MA`, `PhD`, `Ma`, `Jr.`, `Berg MA`, `Prof.`, `Berg Ph. D.`, `V`, `VI`, `X.Y.Z.`, `née Smith`, `Berg Jr. PhD`, `Berg van`, `, Jr.`, `, John` (a comma tail written against the run) -- 395,148 texts, 1,185,444 parses under the three orders, comparing the seven fields and every report (kind, detail, tokens): 774 role moves (258 texts under each order), no report-only move. Every move holds `St und`, the joined piece carrying the title tag, which master merged into the particle run in front with the tag kept and read as a title (`Freiherr Do St und Berg MA` → title 'Freiherr Do St und Berg', family 'MA'); now title 'Freiherr', family 'Do St und Berg', suffix 'MA', the name part read alike with and without the trailing 'MA'. Not a released reading: 1.4.0 reads first 'Do St und Berg', last 'MA', and 2.3.0 family 'Do St und Berg MA', and over the 258 moved texts under the default order master's seven fields equal 2.3.0's on 152, the tree's on none. The S2 stress grid (#614's recipe, above): no role move, and one text's report under its four policies -- `de Mesnil Ma do mc and Ph. D.` no longer reports 'do': P3 joins `mc and Ph. D.` into one suffix-tagged piece, the merge folds 'do' into it, and the peel takes the piece whole rather than picking 'do', the report adf6da88 did not make either and #620 added (its entry above). The #624 title-particle head grid (decisions/P2, 2026-10-10): 0. Every corpus name and case text, and #624's comma-tail grid, under the three orders: 0. The five differential gates exit 0, their reports identical line for line to the tree before. The 2026-10-08 prototype's 80 moves (`Dr. mc mc`) are not in the population: #624 already reads `Dr. Mc Mc` as H3 states. Recorded negative controls (2026-10-10): with the merge off, 30 tests in tests/v2 fail (32 over the whole suite, measured at the review's fix), `test_an_absorbed_particle_run_reports_as_the_one_word_the_chain_makes` among them; with the report keyed on `j > k + 1` alone, the case rows `a_particle_takes_the_dual_behind_it_at_the_head` and `a_particle_run_before_the_run_stays_one_name_word`; with the merge starting at the segment's second piece rather than past the lead, 13; with `title` kept, only `test_a_merged_particle_run_is_a_particle_and_no_title`, the unit test added for it. Cost: +3 frames on the reference row, which holds `de la` (decisions/parse-cost, this date). + THE REVIEW (2026-10-10) found the change reaching further, every shape garbage, none given a row (Derek): no move goes the other way, and nothing moved over comma shapes (an empty or nickname-only head, a family comma's given part), connective-separated runs, runs beside a maiden marker or holding a nickname, bound given names, one-case names, or caller lexicons adding title or suffix words as particles. (1) The `St und` class is any P3 join whose left word is in both the title and particle vocabularies, `und`, `and` or `of` (`y` moves nothing: master already reads `Freiherr Do St y Berg MA` as now), a caller's title-particle included (`Freiherr Do Freiherr and Berg MA`: master title 'Freiherr Do Freiherr and Berg', family 'MA'; now title 'Freiherr', family 'Do Freiherr and Berg', suffix 'MA'). (2) A title-particle head, a particle run, then a title and a dotted word (`Freiherr van der Dr. G.J. MA`, 88 texts on its grid under each order): the run now counts as one name word, so 'G.J.' has none to spare, the read takes nothing and group's own chain takes 'Dr. G.J.' -- family 'van der Dr. G.J.', suffix 'MA', as the one-particle `Freiherr van Dr. G.J. MA` reads (master title 'Freiherr Dr.', family 'van der', suffix 'G.J. MA'). (3) H3's give-back behind a leading title where every word is suffix vocabulary, the last two a run of duals (`Dr. vd Jr. mc mc`, about 1,800 parses over two grids): the merged run is a particle piece and no suffix word, so the title stays -- title 'Dr.', family 'vd', suffix 'Jr. mc mc', where master gave 'Dr.' back as the given name with 'vd' still standing; `Dr. vd Jr. mc` still gives it back. The review also found two runs in one name unreached by any test (an off-by-one in the merge's bookkeeping passed the suite), now the rows `two_particle_runs_merge_each` and `a_second_run_of_duals_is_the_family` (parity) and a half of the merge's unit test, which also holds that a lone particle is no merged run; and the read's title run taken before the merge held only by the frame bands, now `test_the_read_takes_the_title_run_as_written_before_the_merge` (`Dr. mc Jr mc mc`, which with the run asked again after the merge, by the read or by group, reads family 'mc Jr mc mc'; the fix's own review found the group-side half unheld by a read-level test, so it pins the parse too). +- 2026-10-17 #682 — THE RULE WAS SPLIT, AND SO WAS THIS LOG. rules/S2 had grown to seven rules, and each now has its own ID and its own decisions entry, which took the entries written under this key about it: decisions/S3 (the roman-chunk retirement and the digit-dotted tokens), decisions/S4 (the count and the slots that report), decisions/S5 (the given part's trailing slot after a family comma, #531, #573, #578), decisions/S6 (written case, #289, with the review and measurement rounds of the #289 + #516 arc), decisions/S7 (company, #544), decisions/S8 (the credential's run, #602, #627, #679) and decisions/S9 (the by-shape class, #516's two switches, #563, #564). What stays here is the trailing read itself (#614, #620, #625), which rules/S2 kept. The entries keep their text as written: one dated before this entry that says rules/S2 means the rule as it then stood. diff --git a/docs/design/decisions/S3-credentials-written-with-interior-periods.md b/docs/design/decisions/S3-credentials-written-with-interior-periods.md new file mode 100644 index 00000000..a9c67d26 --- /dev/null +++ b/docs/design/decisions/S3-credentials-written-with-interior-periods.md @@ -0,0 +1,5 @@ +# S3 — credentials written with interior periods + +- 2026-09-15 (Derek) — THE ROMAN-CHUNK ACCIDENT RETIRES, NARROWLY. rules/S3's chunk rule survives except where every chunk the vocabulary matches is a SINGLE ASCII CHARACTER — the accident exactly. Measured, `{w for w in (L.suffix_acronyms | L.suffix_words) if len(w) == 1}` is `{i, v, 2}` plus the glued CJK honorific tails `{様, 殿, 氏, 군, 님, 씨, 양}`, so "single ASCII character" names the first three and only those — CHARACTER because `2` is a digit and is in the set, ASCII because `씨` is the one that must KEEP its chunk claim (`J.씨`). So `John Smith R.A.I.` and `John Smith J.u.n.i.o.r.` reach the shape class and read suffix by POSITION with the same fields plus the report, `Jack X.Y.I.` reads family where it read suffix (1.4.0's reading), and `Msc.Ed.`, `JD.CPA`, `Lt.Gov.`, `J.씨` keep their chunk-derived readings. The WIDE retirement — any chunk match yielding to the shape — was MEASURED AND REJECTED: it moves three more corpus names, `Doe, John Msc.Ed.` losing a real credential to `middle` and the two `김민준씨, J.씨` rows re-routing their honorific peel, for nothing this design wants. +- 2026-09-18 — DIGIT-DOTTED TOKENS MOVE, SILENTLY, AND IT IS ACCEPTED. The narrow retirement's set of single-ASCII-character suffix entries is `{i, v, 2}` — the roman numerals AND the lone digit — so a dotted token whose only chunk match is that digit loses the chunk claim, and the SHAPE class cannot take it either: `period_joined_vocab`'s shape verdict wants every chunk `isalpha()`, which no digit is. Measured against 1f78bef: `John Smith 1.4.2` suffix `1.4.2` → family `1.4.2`; `Smith, 1.4.2` family `Smith`/suffix `1.4.2` → given `1.4.2`/family `Smith`; `John Smith, 1.4.2` given `John`/family `Smith`/suffix `1.4.2` → given `1.4.2`/family `John Smith`. No report at any policy, and the switch does not reach it either way. ACCEPTED: a version string read as a credential was the same accident the retirement removes, and the three readings are pinned as case rows. `John Smith 1.4` was already family at 1f78bef and is unaffected — two chunks, neither a match. docs/release_log.rst's wording is corrected from "a single-character roman numeral" to the digit-inclusive form, which is what the code tests. +- 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/S4-ambiguous-acronym-needs-words-to-spare.md b/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md new file mode 100644 index 00000000..faec3522 --- /dev/null +++ b/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md @@ -0,0 +1,9 @@ +# S4 — the ambiguous acronym needs words to spare + +- 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. + 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. +- 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/S5-given-parts-trailing-slot-after-a-family-comma.md b/docs/design/decisions/S5-given-parts-trailing-slot-after-a-family-comma.md new file mode 100644 index 00000000..5d124092 --- /dev/null +++ b/docs/design/decisions/S5-given-parts-trailing-slot-after-a-family-comma.md @@ -0,0 +1,8 @@ +# S5 — the given part's trailing slot after a family comma + +- 2026-09-18 — THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE, AND THE GIVEN SEGMENT'S TRAILING SLOT IS AN OPEN FOLLOW-UP. `assign`'s family-comma emitter reads `state.pieces[1][0]` — the first piece after the comma — so a class member standing at the END of the given part is never asked about. PRE-EXISTING and unchanged by this arc: `Doe, John MA` reads middle `MA` and `Doe, John X.Y.Z.` middle `X.Y.Z.`, both silent, at 1f78bef and on this tree alike. What this arc adds is names that MOVED into that silence: `Doe, John J.u.n.i.o.r.` and `Doe, John X.Y.I.` went suffix → middle, and `John Smith nee Jones R.A.I.` suffix → maiden (the maiden walk absorbs it). `Freiherr von Berg X.Y.I.` was listed here too and does not belong: corrected in the verification round of 2026-09-18, it moved suffix → family but GAINED a report rather than losing one. At 1f78bef and on the 2.3.0 wheel it read title `Freiherr`, family `von Berg`, suffix `X.Y.I.`, reporting `particle-or-given` alone; on this tree it reads title `Freiherr`, family `von Berg X.Y.I.` and reports `particle-or-given` AND `suffix-or-name`, the chain emitter taking it by its shape tag. The role moved into a slot that does not report and the report came from the chain instead, so the name is evidence for the emitter rather than for the silence. The universal claims are narrowed to what ships rather than the emitters widened: `nameparser/_types.py`'s `SUFFIX_OR_NAME` member now names the slots it covers and the one it does not, and rules/S3's "and the same at a comma" now says which parts of a comma form it means. Widening it is a MAINTAINER DECISION and is left open, filed as [#531](https://github.com/derek73/python-nameparser/issues/531): the slot is `_assign`'s given-part walk, the fork is genuine there, and whether a middle initial's neighbourhood should start reporting is a judgement about noise (rules/A1's "a kind is worth adding only if a reader would hesitate too" applies to a SLOT the same way). And the open question is a 1.4.0 PARITY question as well as a noise one, which the verification round of 2026-09-18 established and the entry had backwards: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel and middle `MA` at 2.0.0 through 2.3.0 and here, so the silence at this slot is a 2.0-era reading that has never matched v1 — "every release" was the wrong span and it is now "every 2.x release" wherever this is stated (`nameparser/_types.py`'s `SUFFIX_OR_NAME` member, docs/release_log.rst). The dotted sibling does not divide that way: `Doe, John X.Y.Z.` is middle `X.Y.Z.` at 1.4.0 too. So a maintainer widening the slot is choosing between reporting the fork and restoring v1's ROLE, which are different repairs, and only the bare-acronym half has a parity claim behind it. Recorded under 3-0-reevaluations' standing rule that a design citing 1.4 parity as load-bearing says so where the design lives. Two more report movements recorded here rather than repaired: `van MA` LOSES its `particle-or-given` (1f78bef reported both kinds; the caps lean now takes `MA` to suffix, leaving `van` the only name piece, and assign's leading-particle emitter wants more pieces following), and `John Smith Ma Ma` reports ONCE where 1f78bef reported twice (the first `Ma` is a middle name now, not a second declined pick). And a reach of the default-on dotted switch worth naming: `John Smith www.example.com` reads suffix `www.example.com` with a report, where 1f78bef read family — a URL is three alphabetic period-separated chunks and the shape rule cannot tell it from an acronym. +- 2026-09-18 (Derek), #531 — CAPITALS DECIDE FOR `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING. `do` is the one member of the ambiguous credential class (`ba`, `do`, `ed`, `jd`, `ma` — recompute with `sorted(Lexicon.default().suffix_acronyms_ambiguous)`) that is also particle vocabulary, so the trailing slot of the given part and P6's attachment want the same word. Where the writing carries a positive credential lean — an all-caps member in a name written in more than one case — the credential reading wins and P6 stands down: `Doe, John DO` reads suffix `DO` and reports `suffix-or-name`. Every other spelling attaches exactly as it did before, with P6's own `particle-or-given` and no second report: `Doe, John do`, `Doe, John Do`, `DOE, JOHN DO`, `doe, john do` all read family `do Doe` in their own case. THE PAIRING IS THE ARGUMENT, and the accepted cost and the reason are the two halves of it. In ONE CASE the rule cannot tell `NASCIMENTO, EDSON ARANTES DO` from `SMITH, JOHN DO` and reads both as the particle — right about the Brazilian record, wrong about the osteopath, whose `DO` 1.4.0 read as a suffix; both report `particle-or-given`, which is how a caller finds the second. In MIXED CASE it can, and does: `Nascimento, Edson Arantes do` keeps family `do Nascimento` while `Doe, John DO` gives suffix `DO`. Accepted rather than repaired: the one-case reading is the commoner of the two collisions, and the Nascimento record is the name a wrong answer would damage. The other four members are not particles and are unaffected. This is the exception rules/P6's "this attachment outranks the suffix reading (S2)" clause now names. Implementing it takes TWO carve-outs rather than one, which is worth recording because the second is in the REPORT and not the reading: the reading carve-out takes a particle-tagged member only on a POSITIVE credential lean, since `Doe, John do` leans nothing and the positional reading would otherwise take it — the wrong ANSWER, not merely a stray report; and the emitter beside it skips a particle-tagged member it did not itself take, since an emitter gated on tag membership alone reported both kinds on every `do` row. Where P6 owns the fork, P6 reports it, which is mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE read strictly. ONE SHAPE THE PAIRING DOES NOT REACH — a shape, not a spelling, and the difference is the review finding: ANY particle or particle chain standing in front of the caps `DO` takes it out of this slot, because grouping's prefix chain merges the two into ONE particle piece before the slot exists and there is then no lone member for the capitals to lean on. It is a fact about this WORD being particle vocabulary itself, not about particles generally — the caps `MA` in `Doe, John van MA` is not swallowed, reads as the credential, and leaves `van` to attach behind it. `Doe, John van DO` reads family `van DO Doe`, and so do the `de`, `von`, `del`, `la` and `van der` spellings of it (measured 2026-09-19 — parse them and read `family`). Pre-existing and unmoved by #531; `Doe, John van DO` is the case row and rules/P6's example line, and P6's statement carries the carve-out. The mirror shape is silent rather than merely unreached and is recorded on `AmbiguityKind.SUFFIX_OR_NAME`'s fourth boundary: a particle BEHIND the member ends the given part where this walk reads it, so `Doe, John MA do` keeps middle `MA` beside family `do Doe` though the capitals would otherwise have taken it, the attachment that moves `do` running a stage later. +- 2026-09-18 (Derek), #531 — THE COMMA REPORT'S REACH NOW INCLUDES THE GIVEN SEGMENT'S TRAILING SLOT, AND THE OPEN FOLLOW-UP IS CLOSED. The 2026-09-18 bullet above headed THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE recorded this slot as an open maintainer decision and named the two questions it turned on — whether a middle initial's neighbourhood should start reporting (a noise judgement, rules/A1) and whether the silence was a 1.4 parity gap. Both are answered here, and that bullet stands as it landed. The slot now reads and reports: `Doe, John MA` gives suffix `MA` and `Doe, John Ma` keeps middle `Ma`, each saying which way it went. Derek chose to restore the ROLE and report both ways — one rule for both spellings — over a report-only change and over a capitals-only one, because the issue exists in the first place because two spellings of one name disagree. The noise question was settled by MEASURING THE DISAGREEMENT rather than by argument: over a generated sweep of 78 pairs — five listed members and two by-shape tokens in three cased spellings each, plus five controls in one spelling apiece, so 26 words against three name shapes — 48 pairs disagreed about whether the word was a credential or a name, and every one of the 48 disagreed in the same direction, the comma form declining what the comma-less form took. After this change 3 disagree and all three are the lower-case `do` rows P6 owns. The sweep and its allowlist are `tests/v2/test_properties.py`; a slot that answers differently from the same name written without a comma is not a quiet slot, it is an inconsistent one. Recorded under `3-0-reevaluations`' standing rule because v1 parity is LOAD-BEARING for one half of this and explicitly NOT for the other: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel, so the bare-acronym half RESTORES v1's role and the report is all that is new there — its 1.4.0 ledger rule neither retired nor narrowed to `_ambiguities` (nothing below baseline 2.0 can diff on that pseudo-field) but was RE-POINTED to the four names where the writing declines the credential, which keep the 2.0-era middle name against v1. `Doe, John X.Y.Z.` reads middle `X.Y.Z.` at 1.4.0 too, so the dotted half LEAVES v1 and carries a 1.4.0 ledger rule of its own; it moves to match the comma-less `John Doe X.Y.Z.` and rules/S3's shape rule, not to restore anything. BLAST RADIUS, stated the way this log's own rule asks: twenty corpus names move, and only TWO of them were in any corpus before this branch (`Doe, John MA` and `Doe, John X.Y.Z.`, both admitted by #530's own arc) — the other eighteen are this change's own case rows, so what the differential measures on pre-existing data is two names, and the population the rule reaches is a SHAPE (every family-comma listing whose given part ends in a member of this class) that the corpora barely sample. ACCEPTED COSTS, all measured on the differential corpora: `SMITH, JOHN DO` keeps family `DO SMITH` where 1.4.0 read suffix `DO`, paired with the Nascimento record in the bullet above; one-case and caseless names take the credential with no case evidence at all, so `DOE, MARY JO MA`, `doe, john ma`, `田中, 太郎 MA` and `김, 민준 MA` all read a suffix, which is what 1.4.0 read for each of the four — "caseless is inert" holds for the LEAN and not for the outcome, since `is_one_case` answers True for a script with no case and the positional reading then decides; `Doe, John van MA` loses its middle to the family, reading family `van Doe`, suffix `MA` with two reports where it read middle `van MA` in silence, which is `Berg, Jan van Jr.`'s reading arriving through a shape it could not reach before (Derek accepted it 2026-09-18 as P6 working correctly rather than as a cascade to carve out); `Doe, John Prof. MA` gains a TITLE role `Prof.` never had, H5's transparency reaching it once `MA` leaves the walk, landing it on the same answer as `Doe, John MA Prof.`; `Smith, LEED AP` moves under the default-off caps switch alone, to given `LEED`, family `Smith`, suffix `AP` with two reports, so no default reading is at stake; and a genuine middle name that is also a class member is now a credential wherever it ends the given part and is not Title-cased — `DOE, JOHN ED` reads suffix `ED` — which is the same cost the comma-less form has carried since 2.0. NOT REPAIRED HERE and left open: the maiden walk claims `Smith MA` whole in `Doe, Jane nee Smith MA` before this slot exists, so the rule cannot reach it and the name stays silent with maiden `Smith MA` — the same gap #530's close-out saw from the other side with `John Smith nee Jones R.A.I.`, and it is out of scope for #531. +- 2026-10-02 (Derek), #573 — a never-given particle opening the given part after a family comma leaves no word to spare; the member behind it is the surname it heads, unless mixed-case capitals say credential. The record is the P6 section's entry of the same date. +- 2026-10-02 (Derek), #578 — A PARTICLE BETWEEN A CREDENTIAL AND A TITLE-CASE WORD STAYS SILENT, AS IT READS. `Doe, Jane PhD vd Ma` reads given 'Jane', middle 'vd Ma', family 'Doe', suffix 'PhD', reporting nothing. No new rule decides that: S2's given-part slot already says a particle in front of a member takes the member out of the slot, and where the word behind reads as a name the two are one name, asked nothing and reporting nothing — `vd` reads as `van` does (`Doe, Jane PhD van Ma`, the same reading), and S2's own boundary example is `Doe, John van Ma`. The writing is malformed whichever reading is meant: a Title-case `Ma` behind a lowercase `vd` behind a `PhD` is neither a Dutch listing nor a credential run as anyone writes one. A reader leaning anywhere leans suffix (Derek: both words follow a known credential, and the writer likely did not know how suffixes are capitalized), but that is a guess about a writer's mistake, not a convention the writing carries. Declined: (a) reading `vd Ma` as suffixes, which needs code to make a credential speak across a particle — against P2 (a particle joins the word behind it) and #554 (`vd` speaks for nothing behind it); (b) reporting the fork, which needs an emitter on the family-comma path for a decision the particle chain makes before assign knows which read the part gets (AGENTS.md: prefer leaving a fork silent and documenting the omission over machinery for input nobody reads with confidence). Where the writing does speak the parser already follows it: `Doe, Jane PhD vd MA` reads suffix 'PhD vd MA' and reports (#573). Pinned by the case row `a_particle_after_a_credential_heads_a_title_case_name`. +- 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/S6-case-signal-at-the-suffix-slot.md b/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md new file mode 100644 index 00000000..e1526d60 --- /dev/null +++ b/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md @@ -0,0 +1,28 @@ +# S6 — the case signal at the suffix slot + +- 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. +- 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): + fn(name); calls=0 + def counter(frame,event,arg): + nonlocal calls + if event=='call': calls+=1 + sys.setprofile(counter) + try: + for _ in range(n): fn(name) + finally: sys.setprofile(None) + return calls/n +off=Parser(); on=Parser(policy=Policy(unlisted_caps_suffixes=True)) +for n in ('Smith, John','Smith, XYZ'): print(n, calls_for(off.parse, n), calls_for(on.parse, n))"` (both cheaper than an unconsolidated spelling would be, after the caps predicate was consolidated into one shared function, `_vocab.caps_shape_candidate`, called from classify, `ambiguous_class_candidate` and `_segment.py`'s run test alike — the usual "a shared call costs the default a frame" objection does not apply, because every caller's own first conjunct is `policy.unlisted_caps_suffixes` itself, False by default; `tests/v2/test_policy.py`'s own dated comment carries the same two figures and the recipe for `tools/perf/call_count.py` reference readers). The band never moved at either policy — `test_benchmark.py` gates only the comma-less reference name, which no comma-path cost can reach — and `_CALL_BASELINE` is untouched throughout. + NO MECHANISMS ENTRY IS OWED, and here is why: the promoted fact is STATE-OFFSET-CHANNELS' "a pre-token fact is recorded on the ParseState and consulted later" applied to a name-level boolean rather than to offsets, and `ParseState.order` is the precedent that entry and ONE-PREDICATE-PER-QUESTION both already name. That entry's own sentence — "recorded as offsets … or by presence alone where the fact is name-level" — already covers it; the one edit made there is to name `one_case` as the third channel. +- 2026-09-15 (Derek) — ACCEPTED, NOT REPAIRED, TWO SHAPES. (i) `Freiherr von Berg MA` MOVES: it was listed as unreachable by the peel and as staying fix(#424)'s, and measured, the caps lean makes it reachable, because the lean ends P2's chain with no words to spare where the count would not. Today title 'Freiherr', family 'von Berg MA'; designed title 'Freiherr', family 'von Berg', suffix 'MA'; 1.4.0 first 'von Berg', last 'MA'. Three readings exist and the designed one ships; it moves a rules/P2 example line and re-scopes fix(#424)'s ledger rule, whose `fields` gains `suffix`. (ii) `abdul Smith Jr Ma` reads `Jr` as a MIDDLE NAME: the surname lean breaks the peel AT `Ma`, so the unambiguous `Jr` in front of it is never reached. Today given 'abdul', family 'Smith', suffix 'Jr Ma'; designed given 'abdul Smith', middle Jr, family 'Ma'. One case row rather than real data, but the shape is real — a name-leaning ambiguous acronym BLOCKS the peel of a genuine suffix behind it — and the walk stopping at the declined pick is the rule, not a bug the peel owes a repair. +- 2026-09-18 — THE BLAST RADIUS IS THE GATE'S OWN COUNT, NOT A PREDICTION. Measured at 2.3.0 (`uv run python tools/differential/compare.py`, the default baseline, the previous release), and RE-MEASURED after the review round later the same day and again after the verification round that followed it, which is why several sets of figures carrying one date would otherwise sit here: corpus 1234 names, intentional diffs 51, unexplained 0, radar unclassified 7 — SUFFIX_OR_NAME rows the gate reports but no rule yet claims, named so a release note is never quantified from the intentional count alone. The round added TEN corpus names, not the eight first written here (corrected in the verification round of 2026-09-18): `Doe, John X.Y.Z.`, `Dr. 김민준씨, MA`, `John Smith, MD, Ma`, `John Smith, MD, R.A.I.`, `John de Ma`, `Jo김민준씨, DO`, `Kim김민준씨, MA`, `STEVEN HARDMAN, MD, DO, DDS`, `anh van mc`, `毛泽东 MA` — case rows and rules.md example lines both. The verification round itself then added THREE more (`Anh van Do`, `Doe, John MA`, `John Smith, MD, XYZ`, all rules.md boundary examples), taking the gate's count at this baseline from 1231 to 1234. Recompute the delta rather than trusting either figure: load every `tools/differential/corpus*.jsonl` through compare.py's own `_load_entries`, dedup by name in file order, and diff that list against the same list built from `git show :tools/differential/corpus*.jsonl`. That by-name count runs four below the gate's at the 2.x baselines, the gate deduping by (name, order) and four entries carrying a declared order; the DELTAS agree, which is what a "the round added N" sentence is claiming. The round also added a FOURTH rule, `fix(#289/#516) the glued CJK honorific peel reads the case lean` (3 composed CJK names: `Dr. 김민준씨, MA`, `Jo김민준씨, DO`, `Kim김민준씨, MA` — radar-tier by demotion and classified anyway, since a radar diff this arc intended is one a release note may be written from). The four rules carry 20 + 9 + 9 + 3 = 41 of the 51, `John de Ma` having joined the first. The pre-round reading of this paragraph, kept for the shape of the argument rather than the digits: `fix(#289) a written case contrast decides a bare ambiguous acronym` (19 names, incl. `Davis Royce, Ed`, `Freiherr von Berg MA`, `Jack MA`, `Jack Wei Ma`, `John Prof. MA`, `John Smith Ma`, `John Smith, Ed`, `John Smith, MA`, `John Smith, Ma`, `John van der Berg Ma`); `fix(#516) an unlisted dotted acronym is read by position` (9: `Jack X.Y.I.`, `John Smith Q.W.E.R.T.`, `John Smith X.Y.Z.`, `John Smith, A.B.`, `john smith x.y.z.`, `John Smith B.Tech.`, `John Smith C.H.A.`, `John Smith E.S.Q.`, `Smith, E.S.Q.`); `fix(#289/#516) the ambiguous credential class reports at slots that were silent` (9: `John Smith J.u.n.i.o.r.`, `Royce, Ed`, `Smith, A.B.`, `Smith, Ma`, `J.A. K.D.`, `Jack X.Y.Z.`, `John Smith R.A.I.`, `Smith Jr., A.B.`, `Steven Hardman, MD, DO, DDS`). This is LARGER than the plan's original "11 role + 4 report-only" prediction at the default policy — entirely by corpus growth (this arc's own commits admitted the case-row texts above as contract-tier names) and by two mechanisms the runtime prototype that produced that figure predated (`ambiguous_class_candidate`'s existence and the family-comma report's shape-tag branch), never by a wider rule. Recompute with the command above and read the count the gate returns; the numbers here are a snapshot dated 2026-09-18, not a promise. +- 2026-09-18, WHOLE-PR REVIEW ROUND — WHAT IT MOVED, MEASURED BEFORE AND AFTER. Ten findings; the five that changed behavior are here with the shape each was found by, since the shape is what generalizes. (1) A LOST REPORT. Where the prefix chain absorbed an acronym the S2 peel had DECLINED, nobody emitted the flag S2 promises for either reading: `assign` reports from `peel_trailing`'s picks and a pick reaches it only as a LONE piece, so the token it would have reported on no longer existed. Before: `John van der Berg Ma`, `John de Ma`, `Dr. John van Smith Ma`, `John van Smith Ma Jr.` and `John Smith Mc Ma` gave their family names with `ambiguities` empty; after, each names `suffix-or-name` once and no role moves. The emitter is at the chain's own merge and declines where the PARTICLE run took the word (`anh van do`, `anh van mc` — P6's fork, not S2's, and 1.4.0 read both silently). Recompute: parse the five names and read `ambiguities`. (2) TWO STAGES ASKING ONE QUESTION WITH DIFFERENT INPUTS. `_script_segment` asked `is_wholly_suffix` of the post-comma run WITHOUT the `one_case` fact `segment` had recorded one stage earlier, so `Kim김민준씨, MA` read family `Kim김민준씨`, suffix `MA` (씨 unpeeled) while `Kim김민준씨, PhD` read family `Kim김민준`, suffix `씨, PhD` — one name, two credential spellings, two divisions. After: both peel. `Jo김민준씨, DO` and `Dr. 김민준씨, MA` move with them; `김민준씨, MA` does NOT, being wholly one case (Hangul is caseless and `MA` contrasts with nothing), which is the doctrine holding rather than an exception. rules/W3's claim that the two stages differ "in what else they require, not in what they ask of the words" was false until this and is true now. (3) A REPORT ABOUT THE PARSER'S OWN NEW READING. rules/S3's narrow retirement moved `R.A.I.`, `X.Y.I.` and `J.u.n.i.o.r.` from the vocabulary verdict into the shape class, and `segment`'s tail-segment test reads `is_wholly_suffix`, which is blind to that class — so `John Smith, MD, R.A.I.`, `John Smith, Ph.D., R.A.I.` and `John Smith, MD, J.u.n.i.o.r.` gained a `comma-structure` flag 2.3 did not raise. After: quiet, at the default. The test is narrowed to the BY-SHAPE half deliberately — a LISTED member reaches that reading through the case lean, and `STEVEN HARDMAN, MD, DO, DDS` (one case, nothing leans) keeps its flag as the lean's own recorded negative control; `John Smith, MD, Ma` keeps it too. With `unlisted_dotted_suffixes=False` the flag stands, the token being name material there. C2 is amended to state all of this. (4) THE WORDLIST ROSTER. `_vocab.caps_shape_candidate`'s exclusion named ELEVEN of `_lexicon._VOCAB_FIELDS`' thirteen: `Lexicon.default().add(surnames={"dupont"})` with the caps switch on still read `Jean Pierre DUPONT` as given `Jean`, family `Pierre`, suffix `DUPONT` — a caller listing a word as a SURNAME and getting a credential, which is this switch's own worst failure arriving through the one wordlist that says "family name". After: family `DUPONT`, no report. The roster is derived from the field tuple, with a per-field test; `honorific_tails`, the other omission, was already excluded transitively (⊆ `suffix_words` by Lexicon invariant) and joins for completeness. (5) A LISTED MEMBER READ BY SHAPE. A caller may list a DOTTED entry in `suffix_acronyms_ambiguous` ('a.b'), and the whole token then matches that set while `suffix_as_written`'s period-free acronym lookup ('ab') misses it — so the chunk view reached classify's shape branch, `SHAPE_ACRONYM_TAG` rode along, and `_pieces.listed_lean` (which declines wherever that tag is) silenced the caller's own case lean: `Jack A.B.` read family where `Jack MA` reads suffix. After: suffix `A.B.`, the lean applying. Nothing default moves, the shipped ambiguous vocabulary carrying no periods. Also in the round and behavior-neutral: `Policy.unlisted_dotted_suffixes` and `unlisted_caps_suffixes` moved to the END of `Policy` and `PolicyPatch` — inserted mid-class, they had re-bound every positional argument from `strip_emoji` on, which is API for a dataclass that is not `kw_only`; the bool-validation roster is derived from `dataclasses.fields`, the hand-written twin in the tests having named neither new flag; `ambiguous_class_candidate` lost an optional `one_case` parameter no production caller ever passed, and with it a caps branch that answered False for every name the library has parsed; and `one_case` is REQUIRED on the four piece predicates that own it. +- 2026-09-18 — THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT, kept because the failure mode is this log's recurring one. A review round called THREE `one_case` plumbing sites in `_group.py` inert and proposed removing them. Measured with a runtime wrapper over 1,642 names under six policies — the default, both family-first orders, strict commas, and each 2.4 switch flipped — 9,852 parses: the maiden walk's PAIR is inert, 0 parses move, and it is inert BY CONSTRUCTION rather than by corpus luck (`numeral_only` answers off `peeled.numeral`, and the numeral fork is decided before the peel reads a lean). The chain-tail measure is not: dropping it there moves 18, on `John van der Berg Ma`, `John de Ma` and `Freiherr von Berg MA` under every one of the six. Both figures are at the call sites. The population is the DISTINCT union, in file order, of every `tools/differential/corpus*.jsonl` entry, every `tests/v2/cases.py` text, and `tests/test_variations.TEST_NAMES` with the three comma permutations `test_variations_of_TEST_NAMES` builds (no-comma, family-comma, and suffix-comma where the name has a suffix), empty strings dropped; the wrapper patches the call sites in a copy of the module source and rebinds `_pipeline.STAGES`, which binds stage functions at import and would otherwise leave the mutation inert. This entry first carried 2,338 names and 14,028 parses, which that recipe does not reproduce by any reading of it — the distinct union is 1,642, summing the three sources' distinct counts gives 2,202, and not deduping at all gives 2,690 — so both figures were re-measured on this tree in the verification round of 2026-09-18 and restated here and at the call sites. The FINDING was unharmed: 0 and 18 reproduce exactly, on the same three names, under all six policies, and the control that drops all four sites at once moves those same 18, which is what shows the wrapper can report a difference at all. The argument was right for two sites out of three and would have taken a third with it. +- 2026-09-18, VERIFICATION ROUND — THE REPORT INVENTORY AN UPGRADER SEES, 2.3.0 → TREE, AND ITS RECIPE. docs/release_log.rst's comma-report bullet claimed TWO reports go away and named `Steven Hardman, MD, DO, DDS` and `John Smith, MD, R.A.I.`. The second is false and was never measured against the wheel: `John Smith, MD, R.A.I.` reports NOTHING at 2.3.0, having carried a `comma-structure` flag only inside this arc's own development, where rules/S3's narrow retirement raised it and the C2 amendment quieted it again. Over the whole corpus exactly ONE report is lost, and one more is SWAPPED. Measured: LOST — `comma-structure` ×1 (`Steven Hardman, MD, DO, DDS`), `given-or-family` ×2 (`Jack X.Y.I.`, and `e and e`, which is PR #527's conjunction change and not this arc's). GAINED — `suffix-or-name` ×37, `conjunction-or-initial` ×9 (#527's again), `given-or-family` ×2 (`Jack MA`, `Jack MA.`). So `Jack X.Y.I.` is the only name whose report CHANGED KIND on this arc's account, `given-or-family` → `suffix-or-name`, and the bullet now names it as the swap rather than counting it among the losses. Recompute: collect the corpus glob through compare.py's `_load_entries` deduped by name, parse each name on the tree and on the 2.3.0 wheel (a PEP 723 `uv run --no-project` script pinning `nameparser==2.3.0`, per AGENTS.md's warning that a same-tree comparison reports parity), and take the per-name multiset difference of the `ambiguities` kind values in both directions. The kind counts are multisets, not name counts — `e and e` contributes two `conjunction-or-initial` gains on its own — which is why a bullet quantified from the gate's per-rule name lists would not answer this question. +- 2026-09-23 #492 — THE DEFERRED QUESTION the 2026-09-14 predicate paragraph and decisions/S4's 2026-09-19 #533 bullet name — whether a cased suffix token counts as case evidence — IS ANSWERED FOR RENDER ONLY. R5's gate now leaves the suffixes out (decisions/R5, 2026-09-23); the parse-time fact this section rests on keeps counting the judged token exactly as written above, so `Jack MA` still leans credential and `DOE, JANE nee Smith Ma` still reads suffix `Ma`. The split is decided and recorded under R5, with the shapes it makes visible there (`jack MA` at this slot, `john e jones III` at P3's). +- 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/S7-company-of-an-unambiguous-credential.md b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md new file mode 100644 index 00000000..1ab8589e --- /dev/null +++ b/docs/design/decisions/S7-company-of-an-unambiguous-credential.md @@ -0,0 +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 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. + 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. + 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. +- 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/S8-credentials-trailing-run.md b/docs/design/decisions/S8-credentials-trailing-run.md new file mode 100644 index 00000000..c9459141 --- /dev/null +++ b/docs/design/decisions/S8-credentials-trailing-run.md @@ -0,0 +1,13 @@ +# S8 — the credential's trailing run + +- 2026-10-04 (Derek), #602 — A CREDENTIAL AFTER THE NAME CORE STARTS A RUN TO THE END OF ITS PART. An unambiguous Latin suffix word (the Latin restriction retracted by #679, below) of two or more letters standing after two name words with no comma (a lone particle not counting toward the two; qualified by #627, below), or after the given word in the part after a family comma, makes every later word of that part a suffix, except a title word, which reads as a title: `John Smith PhD Jones` → suffix 'PhD Jones', `Eric H. Holder Jr. Attorney General` → suffix 'Jr.', title 'Attorney General'. A name word the run absorbs is reported `suffix-or-name`, a reader hesitating over `Jones` there. Before this the three comma shapes disagreed — middle 'Smith PhD' with no comma, middle 'Jones' and suffix 'PhD' after a family comma, and suffix 'Jr., Jones' after a suffix comma (C2's tail) — and the no-comma reading made a credential a middle name, the one reading nobody intends. The suffix-comma path already read it this way, and the comma spelling of the Holder name always did. + WHAT STARTS THE RUN is one predicate, `_pieces.starts_a_credential_run`, read by the trailing peel (`credential_run`, applied to every finished peel, so assign, P5's reserve, P2's chain stop and the maiden walk see one answer) and by the given-part walk. EXCLUDED, each with its reason: a TITLE WORD — 746 title words are in no suffix set (measured 2026-10-04: `len(L.titles - L.suffix_acronyms - L.suffix_words)` for the default lexicon), many of them surnames (`King`, `Bishop`, `Judge`), so `Mary Jane King Smith` keeps family 'Smith'; a title is read from the end of a name only behind a comma or with a period (H5, Derek). The AMBIGUOUS CLASS, which carries no evidence of which it is. A SINGLE LETTER, in any case: a lowercase `i` or `v` carries no `initial` tag, and found by the v1 off-switch test, `Josep Carod i Rovira` with `i` removed from the conjunctions read suffix 'i Rovira'. A CONNECTIVE. A word that is also PARTICLE or BOUND GIVEN-NAME vocabulary — found by the suite, not foreseen: `abd` is in the credential list (ABD) and heads `Abd Allah`, and `Mohamed Ali Abd Allah` read suffix 'Abd Allah' until it was excluded. The NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one (retracted by #679, below). A word that is also a title (`MD`, `Ms`, `Sr.`) does start the run: position decides, and after the name core it is the suffix. + DECLINED: lifting the credential out — `John Smith PhD Jones` as given 'John', middle 'Smith', family 'Jones', suffix 'PhD', the family-comma path's old reading. It keeps every name word in a name field, but it reads a credential as something written in the middle of a name, which is the reading the rule rejects. + REVERSED BY THIS ENTRY rather than edited: decisions/S5's #578 bullet (`Doe, Jane PhD vd Ma` now reads suffix 'PhD vd Ma', the credential starting the given part's run), and two of the three shapes #544's ACCEPTED LIMITS (decisions/S7) kept out of the company's reach — a title standing between the credential and the member, and a member a particle chain had taken. The case rows `a_title_between_degree_and_member_keeps_it_a_name` and `a_member_the_particle_chain_took_is_out_of_reach` keep their ids and now pin the run. The third, the merged `Ph. D.`, stays out of reach: that piece is outside the walk the run is read over. + MEASURED 2026-10-04, every name in `tools/differential/corpus*.jsonl` parsed on this tree and on aa401d80 with `nameparser.__file__` asserted on each side: 7 of 1,514 move, four of them this entry's own new rules/S2 examples. The three already in the corpus: `Jane van der Berg née Jr Jones` (suffix 'Jr Jones', where the marker's declined clause had left middle 'van der Berg née Jr'), and `John Doe, MD - PhD - FACS` and `Smith, MD - PhD - FACS`, whose dash inside the given part, a word there under every policy (#549), is now in the run: suffix 'PhD - FACS' where they read middle '-', suffix 'PhD, FACS'. The comma-part question — whether a part OPENING with a credential makes the comma a suffix comma (`John Smith, PhD Jones` still reads family 'John Smith') — is #603's. +- 2026-10-10 (Derek), #627 — A LONE PARTICLE WITH THE RUN RIGHT BEHIND IT COUNTS AS A NAME WORD. #602's run start needs two name words before the credential, and "a lone particle is not a name word for the count", for `de Mesnil`, where the particle and the word behind it are one surname. Since #620 the count is taken in the chain's units, so a particle that opens a unit counts once (`van der`, `von Berg`) and a P3-joined phrase is one piece (`von und zu`); the lone particle stayed uncounted only where nothing joins it, which is exactly where a credential stands behind it. So `John von PhD Jones` read middle 'von PhD', family 'Jones' -- the chain later taking the credential as the particle's surname -- while `John van der PhD Jones` and `John von und zu PhD Jones` read family 'van der' / 'von und zu', suffix 'PhD Jones', and the comma spelling `Smith, John von PhD Jones` already read suffix 'PhD Jones'. The issue's question was which way to make them agree. + DECIDED (Derek): the particle stops at the credential, so it is a name word of its own and the rest is suffix: `John von PhD Jones` → family 'von', suffix 'PhD Jones'. The input is malformed (a particle is not written before a post-nominal), so the decision is whichever reading the shared count already gives the other forms, not a special case for this one: the existing clause gains its own limit, a lone particle counting when the run starts right behind it, in `run_start`'s one loop. The caller without the chain's units (`trailing_start`) shares the expression so the two cannot disagree, but no reading shows that half: it changes the position it returns for `Smith, John von PhD Jones` (2 where master gave 4), so group's chain takes its stopped path and asks `trailing_start` again, reaching the same reading at 16 more frames (339 → 355 on py3.11; a rare shape no frame guard sees, decided for the shared expression over a per-caller copy); a mutant ignoring the flag there passed the suite with the grid byte-identical (#627's reviews). The 1.4.0 and 2.3.0 wheels read middle 'von PhD', as they read `von und zu PhD`; the joined forms changed this cycle with #602. + Declined: counting every unit, a lone particle included. It moved 2,836 S2-grid parses rather than 2,432, the extra ones all `de Mesnil `, where it counted the surname as two words, and it split the two callers: a leading `van` counted before another particle (`van la Smith Jr. Jones` → suffix 'Jr. Jones') but not before a name word (`van Smith Jr. Jones`), and the case row `van la Smith Secretary Jr. Dr. Smith` caught it. + MEASURED 2026-10-10 against 195c4081 (#630's merge), py3.11, `nameparser.__file__` asserted on each side. S2 stress grid (decisions/S2's #614 entry's recipe, 324,552 parses): 2,432 role moves, no report-only move; every move is a lone particle -- in that grid `van`, or a dual `do`/`mc`/`vd` in any case -- with an unambiguous credential right behind it becoming the family (under the family-first orders the given name, the family staying first), the run taking the rest. The grid's comma shapes do not move; the review found the same move in shapes the grid lacks, as intended: other particles (`von`, `de`, `du`, `la`, `bin`), and a head before a suffix comma (`John von PhD Jones, Jr.` → family 'von', suffix 'PhD Jones, Jr.'). Fingerprint of every `tools/differential/corpus*.jsonl` name and `tests/v2/cases.py` text at 195c4081 under the three orders (1,925 texts, the seven fields and every report): 0 moves; this change adds one text, its own example, which moves. The five differential gates exit 0. Held by the case row `John von PhD Jones` (`fix(#627)`) against the row `van la Smith Secretary Jr. Dr. Smith`, whose lone particle has a name word behind it and still does not count, and the row `Freiherr von vd PhD Jones`, where the particle standing right before the credential is one the run took and the flag is not asked of it (a mutant asking it of every piece passed the suite until that row). +- 2026-10-14 (Derek), #679 — ANY UNAMBIGUOUS SUFFIX WORD OF TWO OR MORE LETTERS STARTS THE RUN, WHATEVER ITS SCRIPT; THE HONORIFIC EXCEPTION IS RETRACTED. #602 excluded "the NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one", and drew that line by script, the vocabulary having no honorific marker. #661 then added Cyrillic and Devanagari GENERATIONAL words the script line refused too, splitting Serbian by the script it is written in (#generational-words). Measured 2026-10-14 over every non-Latin suffix word (51) in six shapes under three orders (918 parses), the exception protected no correct reading: an honorific followed by a name word read as a MIDDLE name, in silence (`राम शर्मा साहब मोहन` gave middle 'शर्मा साहब', family 'मोहन'; `משה כהן ז"ל דוד` middle 'כהן ז"ל'), and dropping it reads the honorific as the suffix it is with the word behind it absorbed and reported, as `John Smith PhD Jones` reads. 360 of the 576 honorific parses move and 285 of the 342 generational ones, the same 120 and 95 under each order; the one-character honorifics (씨 様 氏 殿 군 님 양, and जी, whose vowel sign is no letter) never move, S2's two-letter minimum keeping them out already. No realistic name with a name word after an honorific was found, and no corpus name outside rules.md's own examples moves; of the three S2 examples that had pinned the refusal, two now pin the run, and the mixed-script `Taro Tanaka さん Jones` gives way to `राम शर्मा साहब मोहन`. Recompute: the words are master's `Lexicon.default().suffix_words` (after #681) whose letters are not all LATIN by Unicode name; each goes between same-script name words in the shapes `{a} {b} {w} {c}`, `{b}, {a} {w} {c}`, `{a} {b}, {w} {c}`, `{a} {b} {w} {c} {c}`, `{a} {w} {c}` and `{b} {a} {w} {c}`, under each of the three orders, keyed by the order's NAME (the first count keyed by its first role and lost an order, two orders both beginning with FAMILY); compare `as_dict()` and the ambiguity kinds with `_pieces.starts_a_credential_run`'s script test in and out. Generational words are not a class of their own here: a suffix word is a suffix word, which is what drops `_pieces._all_latin` (#681, the same day) and the need for any honorific or generational marker as #679's roster adds non-Latin words. +- 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 new file mode 100644 index 00000000..92a73032 --- /dev/null +++ b/docs/design/decisions/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md @@ -0,0 +1,26 @@ +# S9 — unlisted words that join the ambiguous class by shape + +- 2026-09-14 (Derek) — #516's DOTTED HALF IS A SWITCH, `Policy.unlisted_dotted_suffixes`, DEFAULT ON: an unlisted token of two or more period-separated chunks, any chunk length (`X.Y.Z.`, `B.Tech.`, `Q.W.E.R.T.`), joins the ambiguous class BY SHAPE; whole-token vocabulary still wins (`M.A.`, `Ph.D.`, `A.B.C.` — `abc` IS a suffix acronym). The positional rule then decides and reports either way: `John Smith X.Y.Z.` → suffix, `Jack X.Y.Z.` → family, `Smith, A.B.` → given, `John Smith, A.B.` → suffix. Case is irrelevant — the periods are the evidence — so `john smith x.y.z.` reads as its mixed-case twin. Switch OFF: name material everywhere as 2.3 read it, and the fork is STILL reported, the parser having chosen the name reading over a credential one. +- 2026-09-17/18, #516 review rounds — THE SHAPE VERDICT NEEDS TWO FURTHER GATES, both found by measuring after the shape verdict shipped. (i) ALPHABETIC: `period_joined_vocab`'s "shape" branch now requires every chunk to be `.isalpha()` — a bare digit chunk is not an acronym letter by any reading, so `Smith, 1.4`, `John Smith 1.4` and `John Smith, 1.4` stay unclaimed (no report, no role move), where an unguarded shape verdict had wrongly admitted them; the delimited `Bridge (1.4)` control only proved digits reach extract's escape, saying nothing about the same digits undelimited, which is the gap this closes. (ii) INITIALLESS-SCRIPT: the same test `is_title_shaped` already makes for H2's own leading-abbreviation inference (#323) — `text.isascii() or not in_initialless_script(text)` — now gates the shape verdict too, so `John Smith 田.中.`, `김 민준 이.박.` and `John Smith たな.か.` stay family: a script with no period abbreviations at all has nothing for interior periods to abbreviate, so a CJK word glued into period-separated characters is not spelling an acronym either. `John Smith 田.中.` is pinned `tolerated=True` rather than `shape=` — a Latin period convention glued onto CJK characters is a composed form no writing system produces, and the case table's own validator refuses a Latin shape tag on CJK text; `이.박.` and `たな.か.` are covered by direct `period_joined_vocab` assertions instead of a second corpus row each. +- 2026-09-17, #516 review round — `SMITH, J.R.` READS BY SHAPE FOR A REASON UNRELATED TO WHY IT DECLINES THE LISTED WORD. `_normalize` keeps interior periods ON PURPOSE (v1 parity, pinned 2026-07-17: `'J.R.'` must not collapse to the listed suffix word `'jr'`), so `ambiguous_class_member` correctly answers False for `Smith, J.R.`, `Smith, E.S.Q.` and `Smith, E.T., Jr.` for a reason that has nothing to do with #516. What #516 adds is a SEPARATE route: each chunk (`'j'`/`'r'`, `'e'`/`'s'`/`'q'`, `'e'`/`'t'`) is an unlisted single-letter chunk nothing in the vocabulary claims, so the token joins the class BY SHAPE instead, exactly as `'K.D.'` does in `J.A. K.D.`. The READING is unchanged in all three — one pre-comma name word is never enough to flip the structure — but each now gains `suffix-or-name`, the class having been considered and declined rather than never reached at all. + `is_wholly_suffix` NEVER READS THE BY-SHAPE CLASS. A first version admitted a by-shape member there unconditionally, reasoning it was needed for `John Smith, A.B.`'s structure flip; proved wrong by mutation — that flip already goes through `_segment.py`'s own `pre_comma_names >= 2` disjunct alone, and nothing else in the suite depended on the admission except this predicate's own two unit tests. Left in, the admission combined with C1's LEGACY token-count disjunct (which counts WORDS, not NAME words, and predates the name-word-count decision of decisions/S6, 2026-09-14) to flip `Smith Jr., A.B.` to given `'Smith'`, suffix `'Jr., A.B.'` with a self-contradicting "holds 1 name words" report. Fixed by dropping the admission outright: the by-shape class reaches the comma form ONLY through `_vocab.ambiguous_class_candidate`, never through `is_wholly_suffix`. `Smith Jr., A.B.` now reads given `'A.B.'`, family `'Smith'`, suffix `'Jr.'` — the same shape the LISTED class reads for `Smith Jr., Ma` and `Smith Jr., ma` (given `'Ma'`/`'ma'`, family `'Smith'`, suffix `'Jr.'`), not for `Smith Jr., MA` (given `''`, family `'Smith'`, suffix `'Jr., MA'` — the caps lean has already moved that one into the suffix run, measured on this tree; at 2.3.0 it read given `'MA'`, family `'Smith'`, suffix `'Jr.'`, the very shape `Smith Jr., A.B.` reads now) — with the report; classification `parity`, not `fix(#516)`: FULL PARITY on the 1.4.0 wheel (first A.B., last Smith, suffix Jr.), only the report being new. +- 2026-09-14 (Derek), corrected 2026-09-18 — #516's ALL-CAPS HALF IS A SWITCH TOO, `Policy.unlisted_caps_suffixes`, DEFAULT OFF, and the asymmetry is the decision. An unlisted all-caps word of two or more letters, with no period ANYWHERE (`isalpha()` rejects a period anywhere, not merely an interior one, so a single TRAILING period, H2's own shape, is unaffected either way), in a name `one_case` says is written in more than one case, joins the class by shape (`_vocab.caps_shape_candidate`). + UNLISTED MEANS IN NO WORDLIST AT ALL, not merely "no whole-token suffix vocabulary" — found only by measuring after a narrower, tag-based guard shipped and reversed: `titles`, `given_name_titles`, `particles`, `particles_ambiguous`, `conjunctions`, `conjunctions_ambiguous`, `bound_given_names`, `suffix_acronyms`, `suffix_acronyms_ambiguous`, `suffix_words` and `maiden_markers` — ELEVEN lists, checked by direct membership rather than by tag, since `segment` calls the shape test before `classify` has tagged anything. Measured with the narrower guard alone (`John Smith DE`, `John Smith NEE` wrongly moved to suffix), and stated here as a recipe rather than a fixed count, since a raw per-list membership tally and a de-duplicated one (a word appearing in two lists, `abu`/`أبو`/`ابو` across `bound_given_names` and `particles_ambiguous`, is one leak, not two) give different totals: `len([w for w in getattr(lexicon, name) if w.isascii() and len(w) >= 2 and w.isalpha()])` per excluded list, run over the shipped `Lexicon.default()`, gives on the order of dozens for `particles` and `particles_ambiguous` and single digits for `conjunctions` and `bound_given_names` — every one of them a wordlist entry the caps shape test's earlier, narrower guard did not check at all. The one figure that reproduces exactly, because ASCII case is what the test needs and every other maiden marker is a non-Latin or non-ASCII spelling the shape test's own `isupper()`/`isalpha()` gate excludes on its own: FOUR maiden markers are ASCII (`geb`, `geboren`, `geborene`, `nee`), out of `len(lexicon.maiden_markers)` total (17, measured 2026-09-18) — `'NEE'`/`'GEB'` are two of those four, capitalized, which is what the narrower guard let through. ACCEPTED GAP, recorded rather than chased: an all-caps German title spelled with `SS` for `ß` (`GROSSFÜRST`) is unlisted by this same lookup too, for a reason predating this switch entirely — `_normalize` uses `lower()`, never `casefold()`, specifically so it does not corrupt `großfürst` to the misspelling `grossfürst`, and classify's own existing `vocab:title` check has never recognized this spelling either. Left for whoever next generalizes the ß-fold question. + THE RUN IS THE CAPS CLASS'S ALONE. The multi-token shape `'LEED AP'` needs a RUN test (`John Smith, LEED AP` → suffix `'LEED AP'`), but the listed and dotted halves are single-token classes by construction — the listed set is inherently bare words and the dotted half never needed more than one token — so testing the run over either of them asks a question this design never posed. An un-narrowed run test measured three reversals before this was caught: `John Smith, Ed Ma` (both listed, Title-case, which #289's OWN lean reads as NAME), `John Smith, ma do` (both listed, lower) and `John Smith, X.Y.Z. A.B.` (both dotted) all wrongly moved to suffix as a run. Narrowed to the caps branch alone; `Smith, LEED AP` still declines and reports, and all three reversals are now pinned identical OFF/ON. + `JACK VI` AND `JOHN SMITH VI` DO NOT MOVE, AND NOT FOR THE REASON A FIRST DRAFT GAVE. `VI` passes the caps shape test and carries both `SHAPE_ACRONYM_TAG` and `vocab:suffix-ambiguous` with the switch on — the predicate reaches it, contrary to what an early write-up of this entry said. What actually holds it unchanged is rules/S2's OWN roman-numeral fork (`_pieces.is_trailing_numeral_suffix`), which claims a bare `'VI'` piece and decrements the peel's walk BEFORE the bare-ambiguous/shape branches beneath it are ever reached — a mechanism this switch does not touch and never held `VI` back through non-membership. + THE COST IS A TWO-WORD CONTRAPOSITIVE, not a flat one. Off by default because French and Korean records write the SURNAME in capitals — `Jean DUPONT`, `Minjun KIM` and `Jean Pierre DUPONT` all read family at 1.4.0, at 2.3 and at this default — and `isupper()` is script-agnostic, so the same decline holds in ANY script with a case contrast (`Иван ИВАНОВ`, `Jean ÉCOLE`, measured). Turning it on does not cost every candidate alike: at TWO words there are no words to spare, so `Jean DUPONT` and `Minjun KIM` report the fork and KEEP their family — the class is considered and declined, the mirror of the listed lean's own words-to-spare rule. At THREE words there ARE, and `Jean Pierre DUPONT` gives family `'Pierre'`, suffix `'DUPONT'` — the one genuine cost, and a swallowed family name is the worse of the two failures #vocabulary-collisions C-i already argues from. Default-off is 1.4.0 parity for the whole class regardless; on, nothing but the report changes at two words, and only the three-word shape actually moves. Off, NOTHING is reported either. +- OUT OF SCOPE, each its own issue or entry: no tokenizer split (`J.R.R.`, `A.B.` stay single tokens); the trailing-period-only shape is NOT the signal (`John Smith Xyz.` → family, and AGENTS.md's trailing-abbreviation paragraph stands for bare words and sentence-final stops); no new `AmbiguityKind`; no locale packs; #492 stays deferred. NEITHER SWITCH REACHES THE v1 SHIM, and that is a consequence stated rather than repaired: `lenient_comma_suffixes` has no v1 manager either and `_build_snapshot()` simply does not translate it. Switch A is DEFAULT ON, so `HumanName` changes with it and a v1 caller cannot turn it off — the deliberate reading of the bridge, where the facade tracks the parser's defaults and 2.x behavior changes reach it (the #528 precedent). +- 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. + 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. + 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. + 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/generational-words.md b/docs/design/decisions/generational-words.md index e56be1dc..7f47645d 100644 --- a/docs/design/decisions/generational-words.md +++ b/docs/design/decisions/generational-words.md @@ -5,7 +5,7 @@ Closes #661. 31 spelled-out generational words join SUFFIX_WORDS, and one predic - **CLDR is corroboration, not a source.** Its person-name samples write mlađi/млађи (hr, sr — on a woman's name too), младши/младша (bg), ifj. (hu) and जूनियर (hi), but its `generation` field also holds translations of the field LABEL rather than of the word: isizukulwane (zu), जेनेरेसन (ne), نسل (ps) and jẹnereṣọn (pcm) all mean "generation". A word is not added because CLDR has it. The wider roster (fils, yngri, الابن and the rest) is open at [#679](https://github.com/derek73/python-nameparser/issues/679), to be vetted word by word the same way. - **Excluded, each with its reason.** kıdemli (tr): a rank word (Kıdemli onbaşı); Turkish writes Jr. or a parenthesis, (oğul). sóisearach/sinsearach (ga): rank words too; Irish writes Óg and Mór, which are also inside given names (Seán Óg Ó hAilpín), so they are a separate decision and not taken here. ifj./id. (hu): written BEFORE the name (ifj. Kovács János, which H2's shape rule already reads as a title), and a bare `id` would make any trailing ID a suffix. млада (bg feminine of млади): Mlada is a borne given name, and under a family-first order the given name is the trailing word. ml./мл./st.: the abbreviations stay under the #269 deferral in suffixes.py, st. colliding with Saint besides. - **Spaced only, and what that leaves.** bg, ru and uk write a modern Jr. hyphen-glued (Теодор Рузвелт-младши, Дональд Трамп-молодший), which no whole-token set reaches; for those three the gain is mainly the historical the Elder/the Younger names, written title case (Младший), which the lowercase fold matches. -- **The #602 run start reads the letters' script, not ASCII (Derek chose the Latin-script test, 2026-10-14). SUPERSEDED the same day by #679 (decisions/S2): no script is refused now, so the split this bullet accepts is gone.** rules/S2 lets "an unambiguous Latin suffix word of two or more letters" start the run, and `_pieces.starts_a_credential_run` had implemented Latin as `letters.isascii()`, exact while no accented Latin word was suffix vocabulary. This entry adds the first ones (mlađi, mlađa, mladší, starší), so without a change `Pieter Bruegel stariji Jones` would read suffix 'stariji Jones' while `Antonio Pavlović mlađi Jones` kept family Jones -- one language, one construction, split by the `đ`. The test now asks a non-ASCII word whether every letter's Unicode name is a LATIN one; the ASCII test stays first, so an ASCII word pays no frame for it. The same script test is what refuses the non-Latin honorifics (`Taro Tanaka さん Jones` keeps family Jones), and it refuses this entry's Cyrillic and Devanagari generational words with them, though the reason S2 gives for the refusal -- an honorific carries no credential mark -- is not true of them. So Serbian splits by script: `Ana Horvat stariji Jones` reads suffix 'stariji Jones' and `Ана Хорват старији Џонс` family Џонс, and after a comma C1 asks the same predicate, so `Ана Хорват, старији Џонс` reads given 'старији'. ACCEPTED for this entry (Derek, 2026-10-14) and stated at rules/S2's Accepted clause: the vocabulary does not mark which suffix words are honorifics, a script test is the only line available, and only a name word written after the generational word reaches it. Settling it is open at [#679](https://github.com/derek73/python-nameparser/issues/679), where the non-Latin generational words multiply. The first draft of this entry credited the ASCII test to S2's honorific exclusion instead and proposed deleting it, on the evidence that deleting it moved no test and no corpus name. The design-docs review found the attribution wrong and the evidence empty: nothing pinned the test, and deleting it reads `Taro Tanaka さん Jones` as suffix 'さん Jones' and `Гай Плиний Старший Иванов` as suffix 'Старший Иванов'. Both are rules/S2 boundary examples now. #678 was filed on the first draft and corrected. +- **The #602 run start reads the letters' script, not ASCII (Derek chose the Latin-script test, 2026-10-14). SUPERSEDED the same day by #679 (decisions/S8): no script is refused now, so the split this bullet accepts is gone.** rules/S2 lets "an unambiguous Latin suffix word of two or more letters" start the run, and `_pieces.starts_a_credential_run` had implemented Latin as `letters.isascii()`, exact while no accented Latin word was suffix vocabulary. This entry adds the first ones (mlađi, mlađa, mladší, starší), so without a change `Pieter Bruegel stariji Jones` would read suffix 'stariji Jones' while `Antonio Pavlović mlađi Jones` kept family Jones -- one language, one construction, split by the `đ`. The test now asks a non-ASCII word whether every letter's Unicode name is a LATIN one; the ASCII test stays first, so an ASCII word pays no frame for it. The same script test is what refuses the non-Latin honorifics (`Taro Tanaka さん Jones` keeps family Jones), and it refuses this entry's Cyrillic and Devanagari generational words with them, though the reason S2 gives for the refusal -- an honorific carries no credential mark -- is not true of them. So Serbian splits by script: `Ana Horvat stariji Jones` reads suffix 'stariji Jones' and `Ана Хорват старији Џонс` family Џонс, and after a comma C1 asks the same predicate, so `Ана Хорват, старији Џонс` reads given 'старији'. ACCEPTED for this entry (Derek, 2026-10-14) and stated at rules/S2's Accepted clause: the vocabulary does not mark which suffix words are honorifics, a script test is the only line available, and only a name word written after the generational word reaches it. Settling it is open at [#679](https://github.com/derek73/python-nameparser/issues/679), where the non-Latin generational words multiply. The first draft of this entry credited the ASCII test to S2's honorific exclusion instead and proposed deleting it, on the evidence that deleting it moved no test and no corpus name. The design-docs review found the attribution wrong and the evidence empty: nothing pinned the test, and deleting it reads `Taro Tanaka さん Jones` as suffix 'さん Jones' and `Гай Плиний Старший Иванов` as suffix 'Старший Иванов'. Both are rules/S2 boundary examples now. #678 was filed on the first draft and corrected. - **Measurement (2026-10-14).** Six corpus names move, all in corpus_cldr.jsonl and all the CLDR hr/sr renderings carrying mlađi/млађи, each gaining suffix mlađi (or млађи) from a name field; cldr_agreement records 14 changes, every one toward CLDR's fields. The bg and hi renderings do not move because those locales' formats drop the generation field. The run-start change moves no name the corpus held before this entry; its one mover is S2's own `Antonio Pavlović mlađi Jones`. Of the 161 rows of tools/differential/cldr_fields.jsonl whose `generation` value `cldr_agreement.occurs()` in the text, none writing a non-ASCII generation word has a name word after it in the same part (two write given names after `II` across a comma, which ends the run's part). Recompute: `uv run python tools/differential/snapshot.py --since `. - **2026-10-14 #679 — the rest of CLDR's roster, vetted word by word (Derek).** 76 more words join SUFFIX_WORDS (counted against master's set, none removed): the #679 block in suffixes.py, 30-odd languages. Vetting as above, run by three research passes over each language's Wikipedia (langlinks of the Jr./Sr./the Younger/the Elder articles, then the language's own search) and `tools/name_usage.py`. CLDR's `generation` field proved unreliable in about a third of languages, beyond the field-label translations: an adjective written BEFORE the name (ta இளைய, kn ಕಿರಿ, si බාල, az Kiçik, uz Kichik, tk kiçi, ba кесе, ky Кенже, kk кіші), an abbreviation nobody writes after a name (mr ज्यु., pa ਜੂ., kok धा.), a word that means "second" (chr ᏔᎵᏁ, hi द्वितीय, zh 二世 — regnal, and glued), or a word no Wikipedia uses where another is standard. Where the language's Wikipedia writes a different word after names, that word ships instead of CLDR's: lo ຈູເນຍ for ຮຸ່ນລູກ, ti ጁንየር (and am ጁኒየር) for ማንጁስ, or ଜୁନିଅର/ଜୁନିୟର୍ for ଜେଆର୍, ta இளையவர் and ஜூனியர், si ජූනියර්, mk помладиот for јуниор. EXCLUDED, each with its reason: he סניור, which Israelis bear as a surname (אלי סניור and בוריס סניור, found on he Wikipedia), and ja シニア, whose only evidence is weaker: `tools/name_usage.py` gives both spellings the same 96 Wikidata bearers as Latin Senior, with the same citizenships, because Wikidata's Senior surname item carries he and ja labels, so the count measures the English surname and not Japanese writing; left out anyway (Derek, 2026-10-14), beside its Hebrew twin — English `senior` is no suffix either, Senior being a surname (3,864 US Census); hy Ավագ (an Armenian given name, in lists that write the surname first); km ជូនៀ (a given name; ជូនៀរ ships); Ajala (a Yoruba surname); kk Джуниор and mk јуниор (a given name or a club name there); mn Жр (the initials ЖР); be мал. (also "figure"); lt jaun.; the single-letter abbreviations mr क. and as ক., which are the initial shape; ne/kok जेआर (rare; also JR); fa پدر/پسر ("father"/"son" without the article that marks ar الابن and he הבן as never a name); and the CLDR forms that are label translations or unattested (cv, chr, ak, blo, kok-Latin, kk-Arabic). PACK-SHAPED, NOT SHIPPED: fr fils/père and the Latin abbreviations ml./st. (cs, sk, sl, hr) are real and standard, but their collisions are with OTHER languages written in the same script — `Marie Fils` (767 US Census bearers of Fils) and `Jiří Dienstbier ml.` beside ML initials and St. as Saint — which is the case a locale pack exists for (the #604 `nic` precedent). No fr or cs pack exists; proposing them is open at [#684](https://github.com/derek73/python-nameparser/issues/684). The Cyrillic мл. does ship: ru, bg and sr all write it after names and nothing collides with it in Cyrillic, so a pack would protect nobody; ст. is barely attested after a name and stays out. Neither existing pack takes anything: tr and az put the junior word BEFORE the name or in a parenthesis, and Turkish baba collides inside Turkish. Measured 2026-10-14: 16 corpus names move, all CLDR renderings in is, ar, fa, gu, kn, ml, ru and cv (cv's rendering through мл.) gaining their generation word as the suffix out of a name field; cldr_agreement's default view records 4 suffix fields newly agreeing (cv, fa, is, ru) and no regression, and 40 still-wrong fields changing across the 16 renderings: 12 suffixes now the generation word alone where CLDR also holds the comma-led credential (`yngri` for `yngri þingmaður`), and 28 family, family_base and given_names fields that lost the word but still disagree. No rendering newly agrees on every role. The Burmese sample glues the Burmese comma ၊ (U+104A) to ဂျူနီယာ and is not reached, the tokenizer not splitting on it. diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 374222da..4f3c21f0 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -55,7 +55,7 @@ Problem shape. A word in a structural vocabulary — a marker announcing a maide ## STATE-OFFSET-CHANNELS — early facts ride the state -Problem shape. A fact known during tokenization matters to a much later stage. Contract statement. A pre-token fact is recorded as offsets on the ParseState (comma_offsets, interpunct_offsets) and consulted later by position — or by presence alone where the fact is name-level, as both interpunct consumers do, or as a single boolean for the whole name, as `ParseState.one_case` is (#289/#516: the written-case fact three later sites read — the trailing suffix slot, the post-comma given slot, and the tail-segment reading — none of which may disagree about it) — rather than re-derived from text. How it works. The offsets survive every intermediate stage untouched; #298's transcription marker rides this channel from tokenize to order resolution (rules T3/W4). `one_case` differs from the offset channels in WHO writes it: it is computed once by whichever of segment and classify needs it first (classify always, segment only where a comma form could turn it on), recorded as `None` for "not asked yet", and never recomputed once set (decisions/S2). Lives in. nameparser/_pipeline/_state.py, produced in _tokenize (the offsets) or in segment/classify (`one_case`). Reach for it when. You are about to re-scan the original string in a late stage to rediscover something tokenize already knew. +Problem shape. A fact known during tokenization matters to a much later stage. Contract statement. A pre-token fact is recorded as offsets on the ParseState (comma_offsets, interpunct_offsets) and consulted later by position — or by presence alone where the fact is name-level, as both interpunct consumers do, or as a single boolean for the whole name, as `ParseState.one_case` is (#289/#516: the written-case fact three later sites read — the trailing suffix slot, the post-comma given slot, and the tail-segment reading — none of which may disagree about it) — rather than re-derived from text. How it works. The offsets survive every intermediate stage untouched; #298's transcription marker rides this channel from tokenize to order resolution (rules T3/W4). `one_case` differs from the offset channels in WHO writes it: it is computed once by whichever of segment and classify needs it first (classify always, segment only where a comma form could turn it on), recorded as `None` for "not asked yet", and never recomputed once set (decisions/S6). Lives in. nameparser/_pipeline/_state.py, produced in _tokenize (the offsets) or in segment/classify (`one_case`). Reach for it when. You are about to re-scan the original string in a late stage to rediscover something tokenize already knew. ## PIPELINE-STAGE-CONTRACTS — the ownership map @@ -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/S2)) 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/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. ## RENDER-HONORS-THE-PARSE — the parse decides it, the views honor it 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 620499cc..25fedfd5 100644 --- a/docs/design/rules/C2-parts-past-the-second-comma.md +++ b/docs/design/rules/C2-parts-past-the-second-comma.md @@ -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/S2 · interacts: C1, H5, P2, P5, S3, S6, S8, S9 · implemented: nameparser/_pipeline/_segment.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py + 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 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 7c08df8f..7c7a53db 100644 --- a/docs/design/rules/S3-credentials-written-with-interior-periods.md +++ b/docs/design/rules/S3-credentials-written-with-interior-periods.md @@ -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/S2 · interacts: C1, H2, W3, S4 · implemented: nameparser/_pipeline/_vocab.py + history: decisions/S3 · interacts: C1, 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 32662437..37aff6ce 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 @@ -27,4 +27,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/S2 · interacts: C1, C2, M2, S2, S3, S5, S6, S7, S9 · implemented: nameparser/_pipeline/_group.py + history: decisions/S4 · interacts: C1, C2, M2, S2, S3, S5, S6, S7, S9 · implemented: nameparser/_pipeline/_group.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 c3cc0aed..a67dfecf 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/S2 · interacts: C1, H5, P1, P6, S4, S6, S7 · implemented: nameparser/_pipeline/_assign.py + history: decisions/S5 · interacts: C1, 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 a3ab6475..a80f8b31 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 @@ -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/S2 · interacts: C1, S4, S5, S7 · implemented: nameparser/_pipeline/_vocab.py + history: decisions/S6 · interacts: C1, 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 25cfc42f..94ac75e9 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/S2 · interacts: C1, M2, P2, P3, P6, S4, S5, S6, S8 · implemented: nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py + history: decisions/S7 · interacts: C1, 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 4cc60428..cfee3532 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/S2 · 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, C1 · 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 7a1eed3c..95c331e9 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 @@ -31,4 +31,4 @@ S9. Rationale: no wordlist holds every credential, and a word's "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/S2 · interacts: C1, S3, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py + history: decisions/S9 · interacts: C1, S3, S4 · implemented: nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 377dec4a..1d678982 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -1138,7 +1138,7 @@ def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: # leans nothing, so the positional reading # would take it -- the wrong answer there, # not merely a stray report - # (decisions/S2, 2026-09-18). + # (decisions/S5, 2026-09-18). # # A FUNCTION since #533 rather than a # condition written here (mechanisms.md diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 1b74b16a..79f049ed 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -282,7 +282,7 @@ def classify(state: ParseState) -> ParseState: # ONE fact per parse, and it is recorded now (ParseState.one_case): # segment writes it first where C2's flag on a tail part asks, and # a fact two stages decide apart is what recording it prevents - # (decisions/S2). + # (decisions/S6). one_case = state.one_case if one_case is None: one_case = is_one_case(own) diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index f07d1b0c..abfa9f27 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -477,7 +477,7 @@ def starts_a_credential_run(piece: Sequence[int], ptags: Set[str], than a numeral, and carries no `initial` tag), a connective, a word that is also bound given-name or particle vocabulary (`abd` heads `Abd Allah`; `vd`, `mc`). No script is refused: an honorific - starts the run as a credential does (#679, decisions/S2), so + starts the run as a credential does (#679, decisions/S8), so `Ана Хорват старији Џонс` reads as `Ana Horvat stariji Jones` does. The split credential group merges ('Ph. D.', the one piece carrying @@ -1178,7 +1178,7 @@ def peel_trailing(rest: Sequence[int], pieces: Sequence[Sequence[int]], # ("Jack MA"), a Title-case one is declined with plenty # ("John Smith Ma"). The lean is the LISTED set's alone: a # token admitted to this class by SHAPE carries no writing - # convention to read, so it takes the count (decisions/S2). + # convention to read, so it takes the count (decisions/S6). # # k < 2 means it is the only piece left, which is not the fork # this reports -- and not a floor the lean moves: the walk diff --git a/nameparser/_pipeline/_segment.py b/nameparser/_pipeline/_segment.py index 7c44f7c6..77e7393e 100644 --- a/nameparser/_pipeline/_segment.py +++ b/nameparser/_pipeline/_segment.py @@ -74,7 +74,7 @@ def segment(state: ParseState) -> ParseState: # The case fact, asked LAZILY: here only C2's flag on a part past # the second comma turns on it. A name that never asks pays nothing; # classify asks for itself later where this did not - # (decisions/S2, and #429's precedent for paying a predicate + # (decisions/S6, and #429's precedent for paying a predicate # twice rather than plumbing a field two sites would not otherwise # share). one_case = state.one_case diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index 530b883d..538f228d 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -250,7 +250,7 @@ class ParseState: #: reading, the prefix chain's own tail measure (_group) and the #: glued-honorific peel's decline of a post-comma run #: (_script_segment) all consult it and must not disagree - #: (#289/#516, decisions/S2). Five stages read it in all -- + #: (#289/#516, decisions/S6). Five stages read it in all -- #: segment, script_segment, classify, group and assign -- plus the #: two predicate layers they read it through (_pieces, _vocab). #: A fact segment records SURVIVES script_segment, the one stage diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index e399cdf4..70f4dce0 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -360,7 +360,7 @@ def is_one_case(texts: Sequence[str]) -> bool: # #289's credential lean, rules/S6: "Written case is evidence beside # the count (S4), and it speaks only in a name written in more than # one case" -- the WRITING evidence that decides ahead of the -# words-to-spare count (history: decisions/S2). +# words-to-spare count (history: decisions/S6). def ambiguous_lean(text: str, one_case: bool) -> Lean | None: """Which way the WRITING leans for a member of the ambiguous credential set: "credential", "name", or None for no lean at all. @@ -538,7 +538,7 @@ def period_joined_vocab( any chunk match yields to the shape, was measured and rejected: it costs 'Doe, John Msc.Ed.' a real credential and re-routes the honorific peel of '김민준씨, J.씨' for nothing this design wants - (decisions/S2). + (decisions/S3). The ALPHABETIC requirement is a SEPARATE, narrower gate on the shape verdict alone (#516 review round, decided by the @@ -782,7 +782,7 @@ def claimed_as_non_name(n: str, lexicon: Lexicon) -> bool: # The candidate test for a comma part read before classify -# (rules/C1, decisions/S2): segment's C2 flag on a tail part. +# (rules/C1, 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 @@ -999,7 +999,7 @@ def name_word_count(texts: Sequence[str], lexicon: Lexicon, 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 - (decisions/S2). The suffix half asks the POLICY-selected + (decisions/S6). The suffix half asks the POLICY-selected predicate, the same one is_wholly_suffix asks, so the two agree about what a suffix word is; the title half asks BOTH the listed lookup and H2's shape test (`is_title_shaped`) -- a bare diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index bf4a38e3..39eb130c 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -226,7 +226,7 @@ set claims goes to ``period_joined_vocab``, which splits it on its periods and, no chunk being a title, calls the whole thing a suffix if ANY chunk is suffix vocabulary -- except where every chunk it matches is a SINGLE ASCII -CHARACTER, which since 2.4 retires rather than claims (decisions/S2): the +CHARACTER, which since 2.4 retires rather than claims (decisions/S3): the one-character suffix vocabulary is the Roman numerals and the digit "2" -- ASCII is load-bearing there, since the seven single-character CJK honorifics in THIS set (様, 殿, 氏, 군, 님, 씨, 양 -- three of them also diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 6b45c646..d26bc1db 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -442,7 +442,7 @@ def _check_cjk_shape_purity(self) -> None: # MOVED by #289, not deleted: 'MA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and is taken with no # words to spare -- the reserve/count this row used to pin no - # longer decides here, the written case does (decisions/S2). + # longer decides here, the written case does (decisions/S6). Case("ambiguous_surname_acronyms", "Jack MA", {"given": "Jack", "suffix": "MA"}, classification="fix(#289)", @@ -456,7 +456,7 @@ def _check_cjk_shape_purity(self) -> None: "what turns on GIVEN_OR_FAMILY. 1.4.0 read LAST 'MA' " "here (family 'MA'), so this row BREAKS 1.4.0 parity " "deliberately, in the direction the East Asian surname " - "wants (decisions/S2) -- corrected 2026-09-17, F3 " + "wants (decisions/S6) -- corrected 2026-09-17, F3 " "review finding: an earlier version of this note " "wrongly claimed the opposite"), Case("ambiguous_surname_acronym_with_suffix", "Jack MA Jr", @@ -466,7 +466,7 @@ def _check_cjk_shape_purity(self) -> None: notes="'Jr' peels first as unambiguous vocabulary; 'MA' is " "then the last piece, and the caps lean takes it with " "no words to spare rather than leaving it the family " - "the old reserve kept (decisions/S2) -- both suffix " + "the old reserve kept (decisions/S6) -- both suffix " "words render as one run"), Case("ambiguous_acronym_is_a_suffix_when_a_family_name_remains", "John Smith MA", @@ -583,7 +583,7 @@ def _check_cjk_shape_purity(self) -> None: # mixed-case name, so it now leans CREDENTIAL and the post-comma # slot takes it with one word to spare where the count alone would # not -- back to what 2.0.0 through 2.2.0 read, though for a - # different reason (decisions/S2, Derek's own #289 comment: + # different reason (decisions/S6, Derek's own #289 comment: # positive evidence outranks position here). Case("comma_ambiguous_acronym_ba", "Smith, BA", {"family": "Smith", "suffix": "BA"}, @@ -2550,7 +2550,7 @@ def _check_cjk_shape_purity(self) -> None: "word beside 'John Smith' is how a surname is written. " "1.4.0 read suffix 'Ma', so this is a deliberate " "parity break in the direction the East Asian surname " - "wants (decisions/S2)", + "wants (decisions/S6)", shape=1), Case("one_case_upper_ambiguous_with_words_to_spare", "JOHN SMITH MA", @@ -2626,7 +2626,7 @@ def _check_cjk_shape_purity(self) -> None: "Counting tokens read it as given 'Smith' with suffix " "'Jr., MA' -- no reading of this string puts the " "family anywhere but where this row does " - "(decisions/S2)", + "(decisions/S6)", shape=2), Case("the_by_shape_class_never_reaches_is_wholly_suffix", "Smith Jr., A.B.", @@ -2669,7 +2669,7 @@ def _check_cjk_shape_purity(self) -> None: "count of name words before the comma decides, and two " "of them make it a credential. 1.4.0 read suffix 'Ed' " "too, so this restores parity rather than opening " - "distance from it (decisions/S2, 2026-09-15)", + "distance from it (decisions/S4, 2026-09-15)", shape=3), Case("the_comma_count_reaches_the_listed_set_lower", "john smith, ma", @@ -2697,7 +2697,7 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the two-word twin of 'Royce, Ed' below, and the case " - "row item 5 of decisions/S2 turns on -- no corpus " + "row item 5 of decisions/S4 turns on -- no corpus " "name has this shape, so this row carries the whole " "weight of that decision", shape=3), @@ -3062,7 +3062,7 @@ def _check_cjk_shape_purity(self) -> None: "and this real credential is not handed to the shape " "class. The WIDE retirement -- any chunk match yielding " "to the shape -- would have read 'Msc.Ed.' as a middle " - "name, which is what rejected it (decisions/S2)", + "name, which is what rejected it (decisions/S3)", shape=2), Case("a_glued_honorific_chunk_is_not_ascii", "J.씨", {"given": "J.", "suffix": "씨"}, @@ -3378,7 +3378,7 @@ def _check_cjk_shape_purity(self) -> None: "without meeting it: three name words, so the count " "reads the capitalised surname as a credential and the " "family becomes 'Pierre'. This row is the argument for " - "the default being off (decisions/S2)"), + "the default being off (decisions/S9)"), # #516 review round (quality-review finding): the TWO-word shape # ('Jean DUPONT') is the row `_policy.py`'s own docstring needed # and did not have -- turning the switch on reports a genuine @@ -3654,7 +3654,7 @@ def _check_cjk_shape_purity(self) -> None: # the switch on -- the caps branch must never ride SHAPE_ACRONYM_ # TAG beside a listed member's own membership tag, which is what # silenced `listed_lean` for these before the fix (F1, decided by - # the reviewer; decisions/S2). Both rows are byte-identical to + # the reviewer; decisions/S9). Both rows are byte-identical to # their DEFAULT-policy siblings above (`caps_ambiguous_leans_ # credential`, `the_lean_reaches_the_post_comma_slot`) and carry # no `shape=` tag for the same reason every other policy-on row @@ -3677,7 +3677,7 @@ def _check_cjk_shape_purity(self) -> None: # #516 review round: UNLISTED means in no wordlist at all, not # merely "no whole-token suffix vocabulary" -- a capitalized # particle or particle phrase must not join the class either - # (F1b, decided by the reviewer; decisions/S2's amendment). + # (F1b, decided by the reviewer; decisions/S9's amendment). Case("caps_switch_does_not_claim_a_capitalized_particle", "John Smith DE", {"given": "John", "middle": "Smith", "family": "DE"}, @@ -3941,7 +3941,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "Ma", "family": "Smith", "suffix": "Jr"}, ambiguities=("suffix-or-name",), notes="the SUFFIX arm of the same count, and the shape " - "decisions/S2 names: 'Smith Jr., MA' is two tokens " + "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 " "states"), @@ -3951,7 +3951,7 @@ def _check_cjk_shape_purity(self) -> None: policy=Policy(lenient_comma_suffixes=False), ambiguities=("suffix-or-name",), notes="MEASURED, and a known rough edge recorded rather than " - "repaired (decisions/S2, 2026-09-18). Under strict " + "repaired (decisions/S4, 2026-09-18). 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 the positional read of " @@ -4030,7 +4030,7 @@ def _check_cjk_shape_purity(self) -> None: "John Smith 1.4.2", {"given": "John", "middle": "Smith", "family": "1.4.2"}, classification="fix(#516)", - notes="ACCEPTED and SILENT, recorded at decisions/S2. " + notes="ACCEPTED and SILENT, recorded at decisions/S3. " "rules/S3's narrow retirement drops a dotted token " "whose every matched chunk is a single ASCII " "character, and the vocabulary's lone digit '2' is one " @@ -4669,7 +4669,7 @@ def _check_cjk_shape_purity(self) -> None: "construction and the positional reading at this slot " "IS the credential. Same answer as the comma-less " "'John Doe MA', which is the whole point " - "(decisions/S2)", + "(decisions/S5)", shape=2), Case("the_given_parts_trailing_dotted_slot_reads_the_credential", "Doe, John X.Y.Z.", @@ -4684,7 +4684,7 @@ def _check_cjk_shape_purity(self) -> None: "at this slot is the credential -- matching the " "comma-less 'John Doe X.Y.Z.' and the 2.4 shape rule " "rather than v1. One name, one new divergence, accepted " - "(decisions/S2)", + "(decisions/S5)", shape=2), Case("the_given_parts_trailing_slot_in_all_caps", "DOE, JOHN MA", @@ -5792,7 +5792,7 @@ def _check_cjk_shape_purity(self) -> None: "'Doe', suffix 'DO', maiden 'Smith'. As pinned then: the same " "word where the capitals speak: a positive credential lean is " "the one spelling that outranks P6's attachment " - "(decisions/S2, 2026-09-18), so the clause gives the word " + "(decisions/S5, 2026-09-18), so the clause gives the word " "up. 1.4.0 read suffix 'DO'"), Case("the_no_comma_do_reads_by_the_count_instead", "Jane Doe nee Smith do", @@ -9059,7 +9059,7 @@ def _check_cjk_shape_purity(self) -> None: # 'Ed' is Title-case in a mixed-case name, so it leans SURNAME and # the post-comma slot declines it exactly as before -- but the # decision is now reported, both directions of the fork being - # worth telling the caller about (decisions/S2). + # worth telling the caller about (decisions/S4). Case("comma_ambiguous_acronym", "Smith, Ed", {"given": "Ed", "family": "Smith"}, ambiguities=("suffix-or-name",)), @@ -9365,7 +9365,7 @@ def _check_cjk_shape_purity(self) -> None: # MOVED by #289, not deleted: 'DO' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and the post-comma # slot takes it with one word to spare rather than falling to the - # given position (decisions/S2). + # given position (decisions/S6). Case("audit_bare_caps_do_after_comma_is_a_credential", "Smith, DO", {"family": "Smith", "suffix": "DO"}, classification="fix(#289)", @@ -9827,7 +9827,7 @@ def _check_cjk_shape_purity(self) -> None: # MOVED by #289, not deleted: spliced, this is 'John MA' plus a # title, and 'MA' is written in capitals inside a mixed-case name # -- the caps lean now takes it with no words to spare where the - # old reserve kept it the family (decisions/S2). + # old reserve kept it the family (decisions/S6). Case("title_word_trailing_run_is_read_to_a_fixed_point", "John Prof. MA Prof.", {"title": "Prof. Prof.", "family": "John", "suffix": "MA"}, @@ -9846,7 +9846,7 @@ def _check_cjk_shape_purity(self) -> None: "break either way and the round decided only which " "reading it is. #289 moves it again: the caps lean " "takes 'MA' as a credential rather than the family " - "(decisions/S2)"), + "(decisions/S6)"), Case("title_word_trailing_reads_as_the_leading_title_does", "John Prof. MA", {"title": "Prof.", "family": "John", "suffix": "MA"}, @@ -9857,7 +9857,7 @@ def _check_cjk_shape_purity(self) -> None: "row: 'MA' is written in capitals inside a mixed-case " "name, so the caps lean takes it with no words to " "spare, so this spelling no longer shows the reserve at " - "work. The one-case row below does (decisions/S2)"), + "work. The one-case row below does (decisions/S6)"), Case("title_word_trailing_keeps_the_bare_acronym_reserve", "JOHN PROF. MA", {"title": "PROF.", "given": "JOHN", "family": "MA"}, diff --git a/tests/v2/pipeline/test_assign.py b/tests/v2/pipeline/test_assign.py index 7f13abfd..0e9a4b27 100644 --- a/tests/v2/pipeline/test_assign.py +++ b/tests/v2/pipeline/test_assign.py @@ -400,7 +400,7 @@ def test_the_trailing_title_is_transparent_to_the_suffix_peel() -> None: # 'Jack MA' shape -- an ALL-CAPS ambiguous acronym in a mixed-case # name leans credential and is taken with no words to spare, so # the reserve that used to keep it the family now takes it as the - # suffix instead (decisions/S2). + # suffix instead (decisions/S6). out = _assigned("John Mr. MA", lexicon=lex) assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.GIVEN) == "John" diff --git a/tests/v2/pipeline/test_comma.py b/tests/v2/pipeline/test_comma.py index 61150a8f..baf39800 100644 --- a/tests/v2/pipeline/test_comma.py +++ b/tests/v2/pipeline/test_comma.py @@ -83,7 +83,7 @@ def test_structure_flips_for_the_ambiguous_class_on_a_name_word_count() -> None: # 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 - # family (#289, decisions/S2). + # family (#289, decisions/S6). assert _decided("John Smith, MA").structure is Structure.SUFFIX_COMMA assert _decided("Smith Jr., MA").structure is Structure.FAMILY_COMMA assert _decided("Smith, MA").structure is Structure.FAMILY_COMMA diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index a92b5f87..f4734f57 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -823,7 +823,7 @@ def test_the_join_never_turns_a_suffix_into_a_name() -> None: # MOVED by #289, not deleted: 'Ma' now leans SURNAME (Title case, # mixed-case name) on BOTH sides of the join, so the two views' # suffix readings still agree and the join stands -- 'abdul Smith' - # given, 'Ma' family (decisions/S2's corpus row). + # given, 'Ma' family (decisions/S6's corpus row). out = _grouped("abdul Smith Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["abdul Smith", "Ma"]] out = _grouped("abdul Smith Berg Ma", lexicon=_AMBIGUOUS_LEX) @@ -921,7 +921,7 @@ def test_the_chain_keeps_an_acronym_assign_will_not_peel() -> None: # as a credential); now 'Ma' leans SURNAME (Title case, mixed-case # name) and the peel declines it regardless of the count, so the # second re-ask absorbs it into the particle run too - # (decisions/S2). + # (decisions/S6). out = _grouped("St John van Berg Ma", lexicon=lex) assert _piece_texts(out) == [["St", "John", "van Berg Ma"]] @@ -945,7 +945,7 @@ def test_the_chain_stops_before_a_bare_acronym_with_words_to_spare() -> None: # MOVED by #289, not deleted: 'Ma' is Title-case in a mixed-case # name, so it now leans SURNAME and the chain's re-ask no longer # stops before it -- 'Ma' joins the particle run instead - # (decisions/S2's corpus row). + # (decisions/S6's corpus row). out = _grouped("John van der Berg Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["John", "van der Berg Ma"]] diff --git a/tests/v2/pipeline/test_pieces.py b/tests/v2/pipeline/test_pieces.py index 88698ac7..08dcc9fa 100644 --- a/tests/v2/pipeline/test_pieces.py +++ b/tests/v2/pipeline/test_pieces.py @@ -380,7 +380,7 @@ def test_peel_trailing_keeps_its_two_piece_floor_under_a_lean() -> None: def test_peel_trailing_takes_a_declined_pick_an_anchor_stands_before( ) -> None: - # #544 reverses the cost decisions/S2 had accepted: the surname + # #544 reverses the cost decisions/S6 had accepted: the surname # lean declines 'Ma', but the unambiguous 'Jr' IN FRONT of it # anchors it, so the walk takes it and reaches 'Jr' too -- and the # member is still a pick, reported as a counted one is. diff --git a/tests/v2/pipeline/test_post_rules.py b/tests/v2/pipeline/test_post_rules.py index c3efa0fa..80a6a420 100644 --- a/tests/v2/pipeline/test_post_rules.py +++ b/tests/v2/pipeline/test_post_rules.py @@ -1178,7 +1178,7 @@ def test_p6_still_claims_an_unambiguous_suffix_particle() -> None: def test_p6_keeps_every_non_capital_spelling_of_the_particle_member( ) -> None: """Capitals decide; the particle rule keeps the rest, and each - reports P6's own kind ONCE (decisions/S2, 2026-09-18).""" + reports P6's own kind ONCE (decisions/S5, 2026-09-18).""" for text, family in (("Doe, John do", "do Doe"), ("Doe, John Do", "Do Doe"), ("DOE, JOHN DO", "DO DOE"), diff --git a/tests/v2/test_parser.py b/tests/v2/test_parser.py index eb688b2d..12872ec6 100644 --- a/tests/v2/test_parser.py +++ b/tests/v2/test_parser.py @@ -206,7 +206,7 @@ def test_ambiguous_acronym_reports_the_reading_it_took() -> None: # MOVED by #289, not deleted: 'MA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and is taken with no # words to spare -- 'Jack' is the only name word left, which is - # what also turns on the GIVEN_OR_FAMILY fork (decisions/S2). + # what also turns on the GIVEN_OR_FAMILY fork (decisions/S6). took_family = parse("Jack MA") assert took_family.suffix == "MA" assert not took_family.family @@ -631,7 +631,7 @@ def test_the_reserve_spares_the_family_the_acronym_fork_would_take() -> None: # # MOVED by #289, not deleted: 'Ma' is Title-case in a mixed-case # name, so it leans SURNAME on both sides of the join and stays a - # name word instead of a credential (decisions/S2). + # name word instead of a credential (decisions/S6). n, m = parse("abdul Smith Ma"), parse("John Smith Ma") assert (n.family, n.suffix) == (m.family, m.suffix) == ("Ma", "") @@ -694,7 +694,7 @@ def test_the_chain_and_the_walk_stop_where_the_peel_begins() -> None: # mixed-case name, so it now leans SURNAME, the # run's read leaves it a name word (S2's read ahead of the # chain since #614, its re-ask before that), so the chain - # takes it into the particle run (decisions/S2). + # takes it into the particle run (decisions/S6). ("John van der Berg Ma", "van der Berg Ma", "")): n = parse(text) assert (n.family, n.suffix) == (family, suffix), text @@ -721,7 +721,7 @@ def test_the_chain_and_the_walk_stop_where_the_peel_begins() -> None: # word (#367), and the acronym it leaves has no words to spare for # assign: the chain keeps it rather than leave it as the family # - # MOVED by #289, ACCEPTED COST (decisions/S2): 'MA' is written + # MOVED by #289, ACCEPTED COST (decisions/S6): 'MA' is written # in capitals inside a mixed-case name, so it now leans CREDENTIAL # and the caps lean makes it reachable where the count alone was # not -- the lean ends the chain's re-ask with no words to spare, diff --git a/tests/v2/test_policy.py b/tests/v2/test_policy.py index ad0e0a8d..f2cda8f1 100644 --- a/tests/v2/test_policy.py +++ b/tests/v2/test_policy.py @@ -871,7 +871,7 @@ def test_unlisted_caps_suffixes_on_reads_an_all_caps_word() -> None: # assertions are about the TRAILING slot EVERYWHERE adds. Default # costs, same harness against master: 'Smith, John' 183 -> 183, # 'Smith, XYZ' 182 -> 182 (the comma test needs two words before - # the comma), 'John Smith, XYZ' 251 -> 266 (decisions/S2). + # the comma), 'John Smith, XYZ' 251 -> 266 (decisions/S9). from nameparser import Parser on = Parser(policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)) diff --git a/tests/v2/test_properties.py b/tests/v2/test_properties.py index 5edfff4f..251009b1 100644 --- a/tests/v2/test_properties.py +++ b/tests/v2/test_properties.py @@ -260,11 +260,11 @@ def test_the_comma_agreement_exceptions_are_all_still_exceptions( #: contrast in the string, so the clause hides it. `own_words` stops #: at the maiden marker (rules/P3), so a member inside the clause #: cannot contribute the case contrast `one_case` is computed from -- -#: which is decisions/S2's own "THE PREDICATE KEEPS THE JUDGED +#: which is decisions/S6's own "THE PREDICATE KEEPS THE JUDGED #: TOKEN IN THE SPAN" (the 2026-09-14 #289/#516 entry) failing #: structurally, the judged token never being in the span at this #: slot. Accepted by Derek 2026-09-19 and recorded in -#: decisions/S2 as the M2 instance of #492's deferred question. +#: decisions/S4 as the M2 instance of #492's deferred question. #: The class is asserted beside the count because a structural #: allowlist cannot notice a new member of it, and a COUNT cannot #: notice a swap -- one pair leaving and another arriving keeps the @@ -2434,7 +2434,7 @@ def _a_case_reading_the_docs_already_own( e i' against 'CAROD E I' are the same pair. TWO: a member of the AMBIGUOUS credential class reading - differently in the two spellings. That is decisions/S2's own + differently in the two spellings. That is decisions/S6's own lean -- an ALL-CAPS bare member of the class reads as the credential and a lower-case one as a name word -- and it decides the COMMA STRUCTURE, so its effect reaches every token of the From 4d3fae572ae6fb2eb4d5d11da095ddea3eb8d700 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 17 Oct 2026 22:19:38 -0700 Subject: [PATCH 3/5] Fix the design-docs review findings on the S2 split (#682) Short-form #S2 and 'under S2' pointers that located entries now in decisions/S3, S5, S6, S7 and S9 become key-form pointers; R4's caps-shape credential points at S9, not S6; S4 states that the case gives way to the count at the first slot after a comma (S6, C1), and its rationale scopes the words-to-spare claim to position; C2 names S7's report beside S8's; R5 interacts with S6. Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/C1-suffix-comma-decision.md | 2 +- docs/design/decisions/H5-trailing-title-run.md | 2 +- docs/design/decisions/M2-maiden-marker-rule.md | 2 +- docs/design/decisions/P3-connectives.md | 2 +- docs/design/decisions/ma-do.md | 4 ++-- docs/design/decisions/suffix-acronym-collisions.md | 6 +++--- docs/design/rules/C2-parts-past-the-second-comma.md | 2 +- .../rules/R4-case-repair-reads-the-unjoined-mark.md | 4 ++-- docs/design/rules/R5-case-repair-gate.md | 2 +- .../S4-ambiguous-acronym-needs-words-to-spare.md | 11 ++++++----- tests/v2/test_render.py | 2 +- 11 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/design/decisions/C1-suffix-comma-decision.md b/docs/design/decisions/C1-suffix-comma-decision.md index b81f7cc3..6d8bc780 100644 --- a/docs/design/decisions/C1-suffix-comma-decision.md +++ b/docs/design/decisions/C1-suffix-comma-decision.md @@ -33,7 +33,7 @@ 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 the #544 entry under S2. +- 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. diff --git a/docs/design/decisions/H5-trailing-title-run.md b/docs/design/decisions/H5-trailing-title-run.md index 44e77187..fbb55915 100644 --- a/docs/design/decisions/H5-trailing-title-run.md +++ b/docs/design/decisions/H5-trailing-title-run.md @@ -13,7 +13,7 @@ - **The inline frame-free gates at the two walk sites were REMOVED in review**, and this is the ONE-PREDICATE-PER-QUESTION half of the entry. Each site had a cheap inline test written to match the walk's own first condition; that is a second implementation of the question, and the measurement that justified it did not survive re-running. The band test runs early in a session where the facade sits at 453, so the "one frame of headroom" claim did not reproduce. With the gates gone the walk costs +1 frame on each entry point, inside the plan's target of two and inside `test_facade_cost_stays_within_its_band`. The walk's own cheapness is where the saving lives instead: the abbreviation-shape test is a compiled regex (a C call, no Python frame) and runs BEFORE the vocabulary call, and almost no name ends in a period-marked word, so the ordinary parse pays one match and stops. - **A pre-existing detail mismatch, recorded and NOT fixed.** #H4's join shape reports `title-or-name` with a `detail` that says the unit was "read as a given name by convention", while under the default order H1 retags the unit to the family — so `Dr. John of Prince` reports that text with the unit in `family`. This rule adds a second input with the same mismatch, `John of Prince Prof.`, and fixes neither: the wording predates this bundle, the fork the kind reports is title-versus-name which no field answers either way, and #H4 already records why the detail names no field for the peel shape. - 2026-09-09 (review round 2) — **A1's transparency holds for the ONE-name-word case too, and the scope written a day earlier was the defect rather than the boundary.** A1 was scoped that morning to inputs where a name word still stands on both sides of the chain, handing the one-word case to #H1 unconditionally: "the title behind that word decides its field as a title in front of it would". #H1 was then reading BOTH ends of the name as one title run, so `Sir John Prof.` read family `John` where `Sir John` reads given `John` — the trailing title changing what the leading one addressed by, which is exactly the non-transparency A1 denies. The clause is now conditioned on there being no run in FRONT of the word: where one stands it addresses and the chained trailing title only joins the title field, so `X Prof.` reads as `X` plus the title for one name word as for two. Where no run stands in front the trailing one still decides, unchanged — `Smith Prof.` reads family `Smith`, `Smith Sir.` given `Smith`. Nothing in the walk moved; the fix is entirely in which run H1 keys, and #H1's 2026-09-09 entry carries it, the measurement and the mutation. The #P5 clause below reads the same way afterwards and gains its second half: a bound given-name word behind a trailing title joins as it joins with the title absent AND lands in the same field. Said of the COMMA-LESS writing, and the review that found the H1 defect measured why the qualifier is needed: after a family comma the reserve reads no peel at all, so the join fires over the trailing title word and takes it into the given name — `Berg, abdul Prof.` reads given `abdul Prof.` where `Berg, John Prof.` reads title `Prof.`. That is PARITY (1.4.0 reads it the same, measured) and a gap in the segment path rather than a boundary of the rule, so it is recorded rather than fixed here: the clause carries the scope, and the case row `title_word_trailing_behind_a_bound_pair_after_a_comma` pins it without making it normative. Tracked as part of #316. (FIXED by #637, decisions/P5, 2026-10-11: the join declines the trailing title word, and the row pins the title.) -- 2026-09-09 (the /simplify round on the bundle's PR) — **the trailing peel and the title chain are ONE reading run to a FIXED POINT, and A1's "ONE peel runs over what stands" was a truncation of it.** Two contrived readings contradicted clauses this section states, both found by an altitude review reading the code against the rule rather than by a test. (1) THE SUBTRACTION MODEL. #P5's reserve modelled assign's reading as the peel's count minus the chain's take, on each of the two views the join compares. That arithmetic equals assign's answer only while the chain's take does not change what the peel would do, and at #S2's bare-ambiguous reserve it does: `abdul rahman MA` reads given `abdul`, family `rahman`, suffix `MA` — the join declining because peeling the acronym unjoined and not joined is a suffix reading the join would change — while `abdul rahman MA Prof.` read given `abdul rahman`, family `MA`, the subtraction having counted `MA` a name word to spare. The Accepted clause below, "a bound given-name word behind one joins exactly as it joins with the title absent", was false as stated. (2) THE TRUNCATED FIXED POINT. The re-peel ran ONCE, so a second title was read half way: `John Prof. MA` reads title `Prof.`, given `John`, family `MA`, and `John Prof. MA Prof.` read title `Prof.`, given `John`, family `Prof.`, suffix `MA` — the re-peel un-peeled the acronym and re-exposed the FIRST title, which nothing then took. Both measured on the branch tip, 2026-09-09. +- 2026-09-09 (the /simplify round on the bundle's PR) — **the trailing peel and the title chain are ONE reading run to a FIXED POINT, and A1's "ONE peel runs over what stands" was a truncation of it.** Two contrived readings contradicted clauses this section states, both found by an altitude review reading the code against the rule rather than by a test. (1) THE SUBTRACTION MODEL. #P5's reserve modelled assign's reading as the peel's count minus the chain's take, on each of the two views the join compares. That arithmetic equals assign's answer only while the chain's take does not change what the peel would do, and at rules/S4's bare-ambiguous reserve it does: `abdul rahman MA` reads given `abdul`, family `rahman`, suffix `MA` — the join declining because peeling the acronym unjoined and not joined is a suffix reading the join would change — while `abdul rahman MA Prof.` read given `abdul rahman`, family `MA`, the subtraction having counted `MA` a name word to spare. The Accepted clause below, "a bound given-name word behind one joins exactly as it joins with the title absent", was false as stated. (2) THE TRUNCATED FIXED POINT. The re-peel ran ONCE, so a second title was read half way: `John Prof. MA` reads title `Prof.`, given `John`, family `MA`, and `John Prof. MA Prof.` read title `Prof.`, given `John`, family `Prof.`, suffix `MA` — the re-peel un-peeled the acronym and re-exposed the FIRST title, which nothing then took. Both measured on the branch tip, 2026-09-09. - **The fix is one function, `_pieces.tail_reading`:** peel, chain, splice the chained pieces out, peel again over what is left, until the chain takes nothing. Assign calls it in place of its inline sequence and all its reports still come from the final peel; the reserve calls it on both views and compares the suffix lists it returns, and both subtractions are deleted. `abdul rahman MA Prof.` now reads given `abdul`, family `rahman`, suffix `MA` under title `Prof.`, and `John Prof. MA Prof.` reads title `Prof. Prof.`, given `John`, family `MA`. One further clause had to be said in the code rather than left to the arithmetic: the join must not take a piece the chain takes (#P5 — "a trailing title word the run takes is no word to spare"), which the subtraction had enforced by accident, by leaving that piece in the tail it compared. Without it `Sir abdul Prof.` joins the title into the given name; with it the reading is title `Sir Prof.`, given `abdul`, as before. `trailing_titles` returns the count it LEAVES STANDING in the same edit, the way `peel_trailing` counts, so the two compose at the call site without arithmetic at all. - **Cost, and what paid it.** The shared helper is one frame on the common path, where the inline sequence was none. It is paid by `_effective_order` taking the segment's pieces and the name-piece indices — the shape every piece-layer predicate takes — instead of a list built for it, which on 3.11 is a comprehension frame on every parse; the reference name stays at 416 parse / 453 facade. The helper returns a bare 3-tuple rather than a NamedTuple for the same budget: a NamedTuple's `__new__` is itself a frame, measured at +1 here. - **How big each defect's class is, measured as the rule states it.** The two readings named above are the shapes that made the defects visible; the class both sit in is the TRANSPARENCY class A1 is stated over, and the two defects reach different parts of it — an earlier wording of this bullet flattened them into one width and named the wrong shape for it. Recipe: take the alphabet `John Smith abdul rahman Prof. Dr. Sir MA Jr. V Xyz. King de née`, form every 3- and 4-word arrangement ending in `Prof.` or `Dr.` whose spelling WITHOUT that last word still fills two of given/middle/family (3,210 of them — the two-or-more-name-words scope the A1 clause is stated for), and compare the seven fields other than `title` against that shorter spelling. rules/H5 says they must be equal. On the branch tip 786 differ; on this commit 698 do, none of them new, and all 698 are the P2/M2 residue the rule already accepts as a boundary — a particle chain or a maiden marker took the trailing word before the H5 chain could see it (`John de Prof.`, `Mary née Prof.`). The 88 that close PARTITION by defect, and that partition is what makes them two blast radii rather than one. 56 of them carry a SINGLE period-marked title word, and every one of those carries a bound given-name word as well — the subtraction model's reach, `abdul rahman MA Prof.` itself and `abdul John MA Prof.` beside it, where the arithmetic counted the acronym a name word to spare. The other 32 carry TWO, which is the truncated fixed point's reach: `John Prof. MA Prof.` and `John Dr. MA Dr.`, where the single re-peel re-exposed a title nothing then took. The two-title members of the 3,210 number 784, which is the population that could have witnessed the fixed point at all: 164 of them differed on the branch tip and 132 still do, every one of those 132 the same P2/M2 residue the 698 are, so the defect accounted for exactly the 32 that closed and no more. Over the same sweep's 82,712 inputs (both comma-less and family-comma spellings, 2 to 4 words) exactly 156 parses move, splitting the same way — 96 carry two period-marked title words, 60 carry one, and all 60 of those are bound-given — and not one mover is a comma spelling, the family-comma segment path reaching neither defect. Every figure in this bullet re-measured 2026-09-09 on this tree against `7ac2218`, the round's parent. diff --git a/docs/design/decisions/M2-maiden-marker-rule.md b/docs/design/decisions/M2-maiden-marker-rule.md index 3f4ac9f3..42cc12df 100644 --- a/docs/design/decisions/M2-maiden-marker-rule.md +++ b/docs/design/decisions/M2-maiden-marker-rule.md @@ -54,7 +54,7 @@ the fullwidth-colon marker (旧姓:佐藤 arrives as one word; the head-peel q One limit is recorded rather than closed. The contiguity test is role plus comma bucket, and role is one-directional: a role change is always a clause edge, but two ADJACENT clauses of the same role are indistinguishable, so 'Jane (z) (domu) Jones' does tag a run across two separate nickname clauses. Nothing reads it — the piece walk never sees role-bearing tokens and the clause drop is scoped to one span — and the parse is identical with and without the phrase mechanism. The clean fix is a per-token structural UNIT id derived from state.segments and state.extracted, which would replace role-plus-bucket with one comparison and close the adjacent-clause case as a side effect. Deliberately not taken here: it changes a mechanism four correctness reviewers had just cleared, inside a pass whose stated business was not to change behavior. It is the shape to reach for if this test ever needs to grow a third term. -- 2026-09-19 (Derek), #533 — THE CLAUSE ENDS AT A TRAILING CREDENTIAL, AND THE DOUBLE CHECK THE 2026-08-22 BULLET DEFERRED NOW EXISTS FOR THE ACRONYM TOO. That bullet stopped the walk before the trailing numeral and left the bare-acronym fork alone, with its reason stated: the acronym fork COUNTS pieces and the walk removes the very pieces it counted, so a peel read over the pieces as they stand said "credential with words to spare" about `John née Jones Smith Ma` and would have left maiden 'Jones Smith', family 'Ma'. The reason was right and is unchanged; what it argued for was a deferral, and the answer is the one the numeral got — ask the peel TWICE, once over the pieces as written and once over the name the take would leave, and stop only where both read the word as the credential. "Left to assign" meant "read in silence", assign never getting the question at all, the clause's tokens carrying the maiden role before pieces exist for it: the end of a maiden clause was the last trailing position in the library where a member of the ambiguous credential class was read without a report. WHICH RULE reads the name left standing is the part the numeral's version did not have to answer, because a numeral reads the same way everywhere and a credential does not. With no comma it is the trailing peel; after a family comma it is #531's slot, where the comma has already settled the count and the writing decides alone; before that comma the words are the family the comma named and a stop would hand one of them to `family` rather than to `suffix`; past a second comma the segment is read as credentials whole and no trailing rule is consulted. The last two READ AS NONE, which is a statement and not a default — the first prototype reported on `Smith, John, Jr nee Jones MA`, where the walk had asked nothing. A FIFTH CASE, found by the design-docs review of this commit and not by the plan, and THE PR REVIEW THEN SETTLED IT THE OTHER WAY — see the review bullet below, which is where the reasoning now lives. The shape: after a family comma the reader may take the word and nothing may then report it. `Jane Doe, Jr nee Smith MA` is a family comma whose segment 1 is `Jr nee Smith MA`, so the given-slot reader is asked and would take the member, but the take leaves that segment as `Jr MA`, post-nominals only, which the credential-run gate reads whole without ever reaching the emitter — and the released word therefore lands in `given`, not in `suffix`. This commit read it as maiden 'Smith', suffix 'Jr MA', silently; the review round declines the stop instead, so the clause keeps 'Smith MA' and reports, which is 2f57ff21's reading. The KEPT direction always reported here, the clause's own emitter being what raises it: `Jane Doe, Jr nee Smith Ma` and `Jane Doe, Jr nee MA` both say so, and now so does `Jane Doe, Jr nee Smith MA`. Pinned by a unit test rather than by a rules.md example line: the name differs from the tree at all five baselines and most of the diff is not this change's (1.4.0 reads title 'Jr', given 'nee', family 'Jane Doe'; 2.0.0 and 2.1.0 title 'Jr', family 'Jane Doe'; 2.2.0 and 2.3.0 already read given 'Jane', family 'Doe', suffix 'Jr'), so an example line would have put five ledger rules into this docs commit to attribute four other changes' readings. THE SECOND CHECK ASKS WHETHER THE READER TAKES THAT WORD, not whether it takes something, and the distinction is not academic: the numeral's existing re-ask tests whether the view's trailing run starts at the view's end, exactly right for a fork that reads one piece, and under it `JOHN NEE JONES SMITH MA PHD` leaves `JOHN MA PHD`, whose peel takes 'PHD' and then DECLINES 'MA' for want of words to spare — so the weaker question answers yes while the member becomes the FAMILY name, which is the 2026-08-22 disaster one word further on. Measured both ways on the prototype; the shipped check compares the view's run start against the member's own index in that view, and `JOHN NEE JONES SMITH MA PHD` keeps maiden 'JONES SMITH MA' with suffix 'PHD' and reports. AFTER A FAMILY COMMA THE CHECK NEEDS #531'S FLOOR, NOT ONLY ITS LEAN: `Doe, Jane nee Smith MA do` leaves `Jane MA do`, where 'MA' leans credential but the 'do' behind it does not read as a suffix (P6 keeps it, and `Doe, Jane MA do` reads middle 'MA', family 'do Doe'), so #531's slot would read 'MA' as a MIDDLE name — and a check that asked only about 'MA' released it from the clause into the current name's middle, a word crossing from one person's name to another's. With the floor the clause keeps 'MA do' whole and reports the 'do'. THE FLOOR ON THE FIRST WORD IS A CLAMP, NOT A VETO, and the first draft got two names wrong by making it one: stated as "a member that is the ONLY word after the marker stays the maiden name" and implemented as "cancel the stop when nothing would be left", it cancels too much wherever the peel consumed that first word AND words behind it — `Doe, J. nee MA ba` peels 'ba' and then 'MA', so the first piece the peel took IS the only maiden word, and the veto handed 'ba' back to the clause, giving maiden 'MA ba' where `Doe, J. ba` reads suffix 'ba'. Clamped to the piece after the marker instead, that name keeps maiden 'MA' and reads suffix 'ba', reporting both. The floor itself is deliberate and diverges from the `née V` / `née PhD` precedent, where the marker declines and stays an ordinary word: certain suffix vocabulary declines the marker, while an ambiguous word is kept by the clause it ends, because that class is the one carrying no evidence of which it is — its members are borne surnames as well as credentials, and nobody writes a credential straight after the marker. 1.4.0 read `Jane Doe nee MA` as last 'nee', suffix 'MA' (measured on the wheel), so this half restores nothing and does not try to. THE `do` PAIR is #531's reading, unchanged and now SHARED as a predicate rather than spelled twice (`credential_at_the_given_slot` in `_pipeline/_pieces.py`; the drift would have been silent, each site's own tests going on passing): `Doe, Jane nee Smith do` keeps maiden 'Smith do', `Doe, Jane nee Smith Do` keeps 'Smith Do', `Doe, Jane nee Smith DO` gives maiden 'Smith' with suffix 'DO', and the comma-less `Jane Doe nee Smith do` reads suffix 'do' — right for the reason the whole change is, since `John Doe do` reads suffix 'do' today, P6 does not run without a comma, and a clause must not change how a word outside it reads. ACCEPTED COSTS, each measured on this tree: `JANE DOE NEE YO-YO MA` reads maiden 'YO-YO', suffix 'MA', a two-word birth surname losing its last word in a one-case record — and `JANE YO-YO MA` reads suffix 'MA' too, so the cost is the one-case reading's rather than this rule's, and in mixed case the writing saves it (`Jane Doe nee Yo-Yo Ma` keeps maiden 'Yo-Yo Ma' and reports); `Jane Doe nee Smith X.Y.Z.` reads suffix where 1.4.0 read last 'X.Y.Z.', the same trade #531 took at its own slot and for agreement with `John Doe X.Y.Z.` rather than for parity; (two costs listed here were WITHDRAWN by the PR review and are no longer costs at all — `Doe, Dr. nee Smith MA` and `Berg, abdul nee Jones MA` both keep their maiden words and report; the bullet below carries the reason); `Jane Smith nee Jones and MA` reads maiden 'Jones and' with suffix 'MA', the take running before the joins (#420) so the connective is a piece of its own when the walk stops; and a report on a declined word adds noise without moving a field, which is #530's stated rule and the trade #531 took at its slot. NOT TRANSPARENT HERE: a trailing title. `Jane Doe nee Smith MA Prof.` is unchanged and silent while `Jane Doe nee Smith Prof. MA` gives maiden 'Smith Prof.' with suffix 'MA' and reports, because the walk reads the trailing credential run and the H5 title chain is read by the rule the walk does not run. Accepted for #533, pinned as a pair of boundary example lines under M2; moving the walk onto the title-aware reading would change the numeral half too, which the 2026-08-22 bullet settled with its own measurements, and the pair belongs to one decision about what "trailing" means inside a clause — a follow-up is drafted for Derek rather than filed here. THE `one_case` PLUMBING AT THESE SITES IS NOW LIVE, and the 2026-09-18 bullet under `### S2` headed THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT stands as what was true then: `numeral_only` answered off the peeled numeral and the numeral fork is decided before the peel reads a lean, so the fact reached only the bare-acronym fork, which that reading discarded. The acronym fork is asked now. Re-measured 2026-09-19 with the same wrapper shape over the same population that entry's recipe names — AND THE PAIR IS THE FINDING: over the corpus as it stood the day before this change, dropping the argument inside the walk moved 0 parses; over the corpus WITH this change's own rows it moves 36 of 10,752 (1,792 names), on six distinct names (`Doe, Jane nee Smith DO`, `Doe, Jane nee Smith Ma`, `Jane Doe nee Smith Ma`, `Jane Doe nee Smith Ma JD`, `Jane Doe nee Yo-Yo Ma`, `John née Jones Smith MA`). The population, the six policies and the denominator are recorded at the call site in `_pipeline/_group.py`, as the 2026-09-18 entry records its own; both were re-run on 2026-09-19 after the review found the figures first written there (1,790 names, 10,740 and 21,480 parses) did not reproduce from the recipe as stated — they had been taken two corpus names earlier in the same day's work. The FINDING was unharmed, 36 parses on the same six names. The plumbing was live either way and the corpus could not show it, which is the blindness mechanisms.md's corpus field note asks to be measured before any "N names move" is written down. +- 2026-09-19 (Derek), #533 — THE CLAUSE ENDS AT A TRAILING CREDENTIAL, AND THE DOUBLE CHECK THE 2026-08-22 BULLET DEFERRED NOW EXISTS FOR THE ACRONYM TOO. That bullet stopped the walk before the trailing numeral and left the bare-acronym fork alone, with its reason stated: the acronym fork COUNTS pieces and the walk removes the very pieces it counted, so a peel read over the pieces as they stand said "credential with words to spare" about `John née Jones Smith Ma` and would have left maiden 'Jones Smith', family 'Ma'. The reason was right and is unchanged; what it argued for was a deferral, and the answer is the one the numeral got — ask the peel TWICE, once over the pieces as written and once over the name the take would leave, and stop only where both read the word as the credential. "Left to assign" meant "read in silence", assign never getting the question at all, the clause's tokens carrying the maiden role before pieces exist for it: the end of a maiden clause was the last trailing position in the library where a member of the ambiguous credential class was read without a report. WHICH RULE reads the name left standing is the part the numeral's version did not have to answer, because a numeral reads the same way everywhere and a credential does not. With no comma it is the trailing peel; after a family comma it is #531's slot, where the comma has already settled the count and the writing decides alone; before that comma the words are the family the comma named and a stop would hand one of them to `family` rather than to `suffix`; past a second comma the segment is read as credentials whole and no trailing rule is consulted. The last two READ AS NONE, which is a statement and not a default — the first prototype reported on `Smith, John, Jr nee Jones MA`, where the walk had asked nothing. A FIFTH CASE, found by the design-docs review of this commit and not by the plan, and THE PR REVIEW THEN SETTLED IT THE OTHER WAY — see the review bullet below, which is where the reasoning now lives. The shape: after a family comma the reader may take the word and nothing may then report it. `Jane Doe, Jr nee Smith MA` is a family comma whose segment 1 is `Jr nee Smith MA`, so the given-slot reader is asked and would take the member, but the take leaves that segment as `Jr MA`, post-nominals only, which the credential-run gate reads whole without ever reaching the emitter — and the released word therefore lands in `given`, not in `suffix`. This commit read it as maiden 'Smith', suffix 'Jr MA', silently; the review round declines the stop instead, so the clause keeps 'Smith MA' and reports, which is 2f57ff21's reading. The KEPT direction always reported here, the clause's own emitter being what raises it: `Jane Doe, Jr nee Smith Ma` and `Jane Doe, Jr nee MA` both say so, and now so does `Jane Doe, Jr nee Smith MA`. Pinned by a unit test rather than by a rules.md example line: the name differs from the tree at all five baselines and most of the diff is not this change's (1.4.0 reads title 'Jr', given 'nee', family 'Jane Doe'; 2.0.0 and 2.1.0 title 'Jr', family 'Jane Doe'; 2.2.0 and 2.3.0 already read given 'Jane', family 'Doe', suffix 'Jr'), so an example line would have put five ledger rules into this docs commit to attribute four other changes' readings. THE SECOND CHECK ASKS WHETHER THE READER TAKES THAT WORD, not whether it takes something, and the distinction is not academic: the numeral's existing re-ask tests whether the view's trailing run starts at the view's end, exactly right for a fork that reads one piece, and under it `JOHN NEE JONES SMITH MA PHD` leaves `JOHN MA PHD`, whose peel takes 'PHD' and then DECLINES 'MA' for want of words to spare — so the weaker question answers yes while the member becomes the FAMILY name, which is the 2026-08-22 disaster one word further on. Measured both ways on the prototype; the shipped check compares the view's run start against the member's own index in that view, and `JOHN NEE JONES SMITH MA PHD` keeps maiden 'JONES SMITH MA' with suffix 'PHD' and reports. AFTER A FAMILY COMMA THE CHECK NEEDS #531'S FLOOR, NOT ONLY ITS LEAN: `Doe, Jane nee Smith MA do` leaves `Jane MA do`, where 'MA' leans credential but the 'do' behind it does not read as a suffix (P6 keeps it, and `Doe, Jane MA do` reads middle 'MA', family 'do Doe'), so #531's slot would read 'MA' as a MIDDLE name — and a check that asked only about 'MA' released it from the clause into the current name's middle, a word crossing from one person's name to another's. With the floor the clause keeps 'MA do' whole and reports the 'do'. THE FLOOR ON THE FIRST WORD IS A CLAMP, NOT A VETO, and the first draft got two names wrong by making it one: stated as "a member that is the ONLY word after the marker stays the maiden name" and implemented as "cancel the stop when nothing would be left", it cancels too much wherever the peel consumed that first word AND words behind it — `Doe, J. nee MA ba` peels 'ba' and then 'MA', so the first piece the peel took IS the only maiden word, and the veto handed 'ba' back to the clause, giving maiden 'MA ba' where `Doe, J. ba` reads suffix 'ba'. Clamped to the piece after the marker instead, that name keeps maiden 'MA' and reads suffix 'ba', reporting both. The floor itself is deliberate and diverges from the `née V` / `née PhD` precedent, where the marker declines and stays an ordinary word: certain suffix vocabulary declines the marker, while an ambiguous word is kept by the clause it ends, because that class is the one carrying no evidence of which it is — its members are borne surnames as well as credentials, and nobody writes a credential straight after the marker. 1.4.0 read `Jane Doe nee MA` as last 'nee', suffix 'MA' (measured on the wheel), so this half restores nothing and does not try to. THE `do` PAIR is #531's reading, unchanged and now SHARED as a predicate rather than spelled twice (`credential_at_the_given_slot` in `_pipeline/_pieces.py`; the drift would have been silent, each site's own tests going on passing): `Doe, Jane nee Smith do` keeps maiden 'Smith do', `Doe, Jane nee Smith Do` keeps 'Smith Do', `Doe, Jane nee Smith DO` gives maiden 'Smith' with suffix 'DO', and the comma-less `Jane Doe nee Smith do` reads suffix 'do' — right for the reason the whole change is, since `John Doe do` reads suffix 'do' today, P6 does not run without a comma, and a clause must not change how a word outside it reads. ACCEPTED COSTS, each measured on this tree: `JANE DOE NEE YO-YO MA` reads maiden 'YO-YO', suffix 'MA', a two-word birth surname losing its last word in a one-case record — and `JANE YO-YO MA` reads suffix 'MA' too, so the cost is the one-case reading's rather than this rule's, and in mixed case the writing saves it (`Jane Doe nee Yo-Yo Ma` keeps maiden 'Yo-Yo Ma' and reports); `Jane Doe nee Smith X.Y.Z.` reads suffix where 1.4.0 read last 'X.Y.Z.', the same trade #531 took at its own slot and for agreement with `John Doe X.Y.Z.` rather than for parity; (two costs listed here were WITHDRAWN by the PR review and are no longer costs at all — `Doe, Dr. nee Smith MA` and `Berg, abdul nee Jones MA` both keep their maiden words and report; the bullet below carries the reason); `Jane Smith nee Jones and MA` reads maiden 'Jones and' with suffix 'MA', the take running before the joins (#420) so the connective is a piece of its own when the walk stops; and a report on a declined word adds noise without moving a field, which is #530's stated rule and the trade #531 took at its slot. NOT TRANSPARENT HERE: a trailing title. `Jane Doe nee Smith MA Prof.` is unchanged and silent while `Jane Doe nee Smith Prof. MA` gives maiden 'Smith Prof.' with suffix 'MA' and reports, because the walk reads the trailing credential run and the H5 title chain is read by the rule the walk does not run. Accepted for #533, pinned as a pair of boundary example lines under M2; moving the walk onto the title-aware reading would change the numeral half too, which the 2026-08-22 bullet settled with its own measurements, and the pair belongs to one decision about what "trailing" means inside a clause — a follow-up is drafted for Derek rather than filed here. THE `one_case` PLUMBING AT THESE SITES IS NOW LIVE, and decisions/S6's 2026-09-18 bullet headed THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT stands as what was true then: `numeral_only` answered off the peeled numeral and the numeral fork is decided before the peel reads a lean, so the fact reached only the bare-acronym fork, which that reading discarded. The acronym fork is asked now. Re-measured 2026-09-19 with the same wrapper shape over the same population that entry's recipe names — AND THE PAIR IS THE FINDING: over the corpus as it stood the day before this change, dropping the argument inside the walk moved 0 parses; over the corpus WITH this change's own rows it moves 36 of 10,752 (1,792 names), on six distinct names (`Doe, Jane nee Smith DO`, `Doe, Jane nee Smith Ma`, `Jane Doe nee Smith Ma`, `Jane Doe nee Smith Ma JD`, `Jane Doe nee Yo-Yo Ma`, `John née Jones Smith MA`). The population, the six policies and the denominator are recorded at the call site in `_pipeline/_group.py`, as the 2026-09-18 entry records its own; both were re-run on 2026-09-19 after the review found the figures first written there (1,790 names, 10,740 and 21,480 parses) did not reproduce from the recipe as stated — they had been taken two corpus names earlier in the same day's work. The FINDING was unharmed, 36 parses on the same six names. The plumbing was live either way and the corpus could not show it, which is the blindness mechanisms.md's corpus field note asks to be measured before any "N names move" is written down. - 2026-09-19 (Derek), #533 review — A WORD THE CLAUSE GIVES UP READS AS A POST-NOMINAL OR THE CLAUSE KEEPS IT, AND THAT INVARIANT REPLACES THE ACCEPTED SILENT MOVERS ABOVE. The PR review measured what the released words actually do, and three of them do not land in `suffix`: `Doe, Prof. nee Smith A.B.` read given 'A.B.' (66 distinct names in the review's own sweep, the same shape reaching `ba`, `X.Y.Z.`, caps-on `XYZ` and the lower-case spellings); `Berg, Jane van der nee Smith DO` read family 'van der DO Berg' and `Jane Doe nee Smith DO DO` read family 'DO DO', a word crossing from the BIRTH name into the current one, which is #424's failure from the other side; and `Berg, abdul nee Jones MA` read given 'abdul MA'. Two of those had been written up as ACCEPTED COSTS in the bullet above, on the argument that the clause form AGREES with the clause-less one. The agreement is real and it is not the question: the clause-less `Berg, abdul MA` puts the word in the given name because nobody promised otherwise, while a CLAUSE promised that the word was either the birth name or a credential, and a third answer is the promise broken in silence. So the direction is the parent's, conservative: THE STOP IS RIGHT ONLY WHERE THE RELEASED WORD ENDS THE PARSE SUFFIX-ROLED, and where the take cannot promise that, the clause keeps the word (and reports it, the emitter asking whether a trailing rule reads these words at all rather than whether the view check then passed). Two view checks carry it, each modelling a LATER decision rather than predicting one: the given-slot reader requires the view to still hold a name word AHEAD of the member, since a trailing slot needs a part to be the end of; and a release is withdrawn where a join reached below the marker pass would absorb the word — P2's chain, for a particle-tagged member behind a particle piece or beside another released one, and P5's bound-given join, for the word after the bound one. Both over-decline rather than predict, which is the direction the invariant asks for. MEASURED, and the pair of numbers is the argument: over the review's 142,518-name corpus under six policies, 855,108 parses, the invariant holds 0 violations at 2f57ff21 and 0 here, against 1,446 in 40 shapes at the commit this round started from; and a token-level diff against 2f57ff21 finds 128,424 moved parses on 24,958 names with ZERO of the moved tokens unnamed by a `suffix-or-name` report, against 4,100 silent moved tokens in 59 shapes before. `tests/v2/test_properties.py` carries the invariant as a property over a generated grid rich enough to hold every shape above; the grid fails on 790 of its 8,466 parses at d97d3eb7 and passes at 2f57ff21, which is what makes it a test rather than a restatement. TWO ACCEPTED SILENCES REMAIN AND ARE NOT SILENCES OF THIS KIND: a member with a name word BEHIND it was never asked about, and a delimited clause is not a fork at all (below). ONE ROUGH EDGE IS RECORDED RATHER THAN FIXED, and it PREDATES this change: with `extra_suffix_delimiters=('-',)`, `Jane Doe nee Smith - MA` gives maiden 'Smith -' — a clause ending on a bare delimiter token. 2f57ff21 does the same thing one word later (`Jane Doe nee Smith - PhD` gives maiden 'Smith -' there too), so what #533 changes is only which words reach the edge. Walking the stop back over it is not the two-line change it looks like: the delimiter-core set the walk already steps over is EMPTY off a tail segment, which is exactly where this name's `-` sits, so the fix would have to widen what counts as a core on every path and would move the parent's reading as well. Left for its own issue. diff --git a/docs/design/decisions/P3-connectives.md b/docs/design/decisions/P3-connectives.md index 5bdb80aa..6f030eff 100644 --- a/docs/design/decisions/P3-connectives.md +++ b/docs/design/decisions/P3-connectives.md @@ -20,7 +20,7 @@ 2026-09-13 #528 — AMENDS the paragraph above: CLOSED THE SAME DAY. `_facade.py` was touched after all — #528 shipped the fix the paragraph predicted, with the R4 shape it predicted: the facade consults the PARSED token wherever the part maps to one and falls back to the vocabulary only where it does not. Both views give "j. e. s." and "J. G." now (measured 2026-09-13 after the fix); the contrastive test the paragraph above describes became the agreement test `tests/v2/test_render.py::test_facade_initials_follow_the_one_case_fork`. `decisions/R3`'s 2026-09-13 entry carries the fix, its accepted costs and the one corpus name where the two views still differ. `capitalize()` was never split: it followed the parse on both surfaces throughout the window the paragraph above describes, giving "John E Smith" for that whole window — not "all along", since 2.3.0, before #383/#479 landed, gave "John e Smith" for the same name. TWO OTHER CAUSES SHARE THE `_initials` FIELD IN THE LEDGERS AND NEITHER IS THIS PR'S, recorded because a reader meeting them under these names will reach for this entry. The Arabic `محمد و علي` diffs on `_initials` at 1.4.0 only (`م. و. ع.` → `م. ع.`), measured byte-identical either side of the fork: it is a pre-existing #269 consequence — a recognized non-Latin connective contributes no initial — surfaced by the row entering the contract corpus, and `expected_since_1.4.0.toml` ledgers it as `feat(#269)`. `JUAN Y GARCIA` diffs on `_initials` at 2.0.0 through 2.2.0 for a DIFFERENT reason than it does at 2.3.0: at those three baselines `fix(#462)` already admits the name (the facade moved `J. G.` → `J. Y. G.` there), and this PR's core move `J. Y. G.` → `J. G.` gets its own rule at 2.3.0 alone. Two causes, one field, documented in a dated paragraph on `fix(#462)` in those three ledgers rather than as a competing rule. Out of scope, each its own issue: #492 (whether a cased suffix token counts as case evidence — `is_one_case` is written so R5 and #492 can share it later, but render does not import it here); #478 (hyphenated connective repair — its spaced-form claim now depends on "y" staying OUT of the subset); #461 (R3's clause); #289 and #516 (the same case-class fact read at the suffix and post-comma slots); the render-side conjunction fallback for spliced raw text, which stays vocabulary-keyed as rules/R4 states. - - 2026-09-15 #289/#516 — AMENDS the out-of-scope list above: #289 and #516 are no longer out of scope, having shipped in 2.4 on the fact this entry's own fork computes. The fact is now a `ParseState` field rather than a classify local, taken over the SAME own-words span this entry defines, and `_pieces.own_words` is where that span lives for both readers. #492 stays out of scope and stays deferred. See #S2. + - 2026-09-15 #289/#516 — AMENDS the out-of-scope list above: #289 and #516 are no longer out of scope, having shipped in 2.4 on the fact this entry's own fork computes. The fact is now a `ParseState` field rather than a classify local, taken over the SAME own-words span this entry defines, and `_pieces.own_words` is where that span lives for both readers. #492 stays out of scope and stays deferred. See decisions/S6. - 2026-09-23 #492/#478 — AMENDS the out-of-scope list above again: both have landed, in RENDER only. #492: R5's gate leaves the suffixes out (decisions/R5, 2026-09-23); this entry's fork is untouched and still counts a cased suffix, which is the R5/parser split that entry records, and render still does not import `is_one_case`. #478: a connective part inside a hyphenated word keeps its lowercase (decisions/R4, 2026-09-23); the spaced-form claim it rested on is unchanged, and still depends on `y` staying out of the subset. - 2026-09-13 #383/#479 — `conjunctions_ambiguous` is deliberately NOT registered in `_SUBSET_FIELDS`, so an orphan marker entry raises nothing. An orphan there is INERT rather than harmful: the classify fork tests membership in `conjunctions` before it reads the subset, and the emitter tests it again, so a marker entry whose base word is gone is never consulted by either. AGENTS.md's invariants rule guards harm, not no-ops, and the precedent is this file's #given-name-titles Declined entry, where two attempts at a check each cost a working configuration to forbid a condition that costs nothing. What holds the SHIPPED constant to the subset relation is an import-time assert in `nameparser/config/conjunctions.py` (which also holds it to cased single letters, the only entries the fork can read), and the v1 shim's intersection, which is a provable no-op. So `remove(conjunctions={"e"})` simply works, leaving a stale marker entry behind that does nothing. Pinned by `test_removing_a_conjunction_leaves_its_ambiguous_marker_alone` in tests/v2/test_lexicon.py and by the orphan behavioral pin in tests/v2/pipeline/test_classify.py, both of which cite this entry. diff --git a/docs/design/decisions/ma-do.md b/docs/design/decisions/ma-do.md index 1ddaedc5..9331c19a 100644 --- a/docs/design/decisions/ma-do.md +++ b/docs/design/decisions/ma-do.md @@ -2,6 +2,6 @@ - 2026-07-17 (M12, Derek-approved) — ma and do joined the ambiguous acronym set because both are common surnames; the two-word "Jack Ma" is kept intact by S2's words-to-spare guard, while the periods gate governs the dotted spellings ("M.A." counts unambiguously). Documented side effect: parenthesized bare "(MA)"/"(DO)" no longer escape to suffix as in 1.x. -- 2026-09-15 #289 — AMENDS the sentence above for one spelling and leaves it standing for the other. "The two-word 'Jack Ma' is kept intact by S2's words-to-spare guard" is still true of `Jack Ma`, and false of `Jack MA`: an all-caps member of the ambiguous set inside a mixed-case name is now read as a credential with nothing to spare, so `Jack MA` gives given 'Jack', suffix 'MA' — v1's reading was last 'MA', so this is a deliberate parity break and not a restoration. The periods gate is untouched and still governs the dotted spellings. The two ledger comments that quote this sentence carry the same amendment. See #S2. -- 2026-09-18 #531 — AND `do` IS NOW SPELLING-DEPENDENT AFTER A FAMILY COMMA, which neither sentence above anticipated: it is the one member of this set that is also particle vocabulary, so `Doe, John DO` reads suffix `DO` (capitals, mixed-case name) while every other spelling attaches to the family under rules/P6. The pairing and its accepted cost are in #S2's 2026-09-18 `do` bullet. +- 2026-09-15 #289 — AMENDS the sentence above for one spelling and leaves it standing for the other. "The two-word 'Jack Ma' is kept intact by S2's words-to-spare guard" is still true of `Jack Ma`, and false of `Jack MA`: an all-caps member of the ambiguous set inside a mixed-case name is now read as a credential with nothing to spare, so `Jack MA` gives given 'Jack', suffix 'MA' — v1's reading was last 'MA', so this is a deliberate parity break and not a restoration. The periods gate is untouched and still governs the dotted spellings. The two ledger comments that quote this sentence carry the same amendment. See decisions/S6. +- 2026-09-18 #531 — AND `do` IS NOW SPELLING-DEPENDENT AFTER A FAMILY COMMA, which neither sentence above anticipated: it is the one member of this set that is also particle vocabulary, so `Doe, John DO` reads suffix `DO` (capitals, mixed-case name) while every other spelling attaches to the family under rules/P6. The pairing and its accepted cost are in decisions/S5's 2026-09-18 `do` bullet. diff --git a/docs/design/decisions/suffix-acronym-collisions.md b/docs/design/decisions/suffix-acronym-collisions.md index fbaf490c..6c179d0c 100644 --- a/docs/design/decisions/suffix-acronym-collisions.md +++ b/docs/design/decisions/suffix-acronym-collisions.md @@ -14,10 +14,10 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **esq left SUFFIX_ACRONYMS (2026-09-08, the #316/#489 bundle).** The same criterion asked of a word the frequency argument cannot reach: does the entry describe the WORD or the machinery. Esquire is a contraction, not an initialism, so the initialism set was the wrong home for it whatever its frequency — this is the criterion's third kind of answer, and it is the one rai and cha did not need. The entry arrived with the same 2019-12-11 bulk import (af5bdab, #93) that brought rai and cha and was never reviewed either; its only unique coverage was the multi-dot spelling `E.S.Q.`, the SUFFIX_WORDS membership carrying every single-token spelling. Measured over the corpus glob: one name moves, `John Smith E.S.Q.`, from suffix to family, at every baseline. What the removal buys is the invariant this entry's neighbours could not have: `SUFFIX_ACRONYMS ∩ SUFFIX_WORDS == ∅` is asserted at import, esq having been the only member of that intersection and the reason the assert could not exist. Recompute the intersection with `L = Parser().lexicon; sorted(L.suffix_acronyms & L.suffix_words)`, which gives `[]` on 2026-09-08. The standing keep-out is the Excluded (SUFFIX_ACRONYMS — esq) block in decisions/vocabulary-exclusions. - **Not decided here:** sa stays where #296's audit put it (title and suffix dual, position decides); se and om stay unambiguous, there being no surname evidence worth standing behind and OM being the Order of Merit; the S2 comma-form boundary ("Smith, Ed" → given Ed) is not reopened. #342's own comment lists sa, se and om beside ba and cha as model recall rather than corpus-attested, and per the #360 lesson they need a human before they move. - **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 #S2 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; #S2 records the three names that pay for it. +- **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). - **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 under S2, 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 the #544 entry under S2. +- **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. 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 25fedfd5..6e38c1cb 100644 --- a/docs/design/rules/C2-parts-past-the-second-comma.md +++ b/docs/design/rules/C2-parts-past-the-second-comma.md @@ -39,7 +39,7 @@ C2. Rationale: text beyond the recognized comma parts should be 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 - first word after the comma), S8's report on each word a credential + 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 rule's flag. diff --git a/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md b/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md index 093ce3c4..d6636f99 100644 --- a/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md +++ b/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md @@ -57,7 +57,7 @@ R4. Rationale: case repair is a display concern, applied only on does not carry is an initialism, so a single-case word the parse put in the suffix role from the acronym vocabulary, or read as a credential by its dotted shape alone (S3) or by its capitals - (S6), repairs to its all-caps spelling rather than a title-cased + (S9), repairs to its all-caps spelling rather than a title-cased one, and that repair outranks the Mac/Mc convention where a word fits both (MCSE, not McSe). A roman numeral the parse put in the suffix role is @@ -186,5 +186,5 @@ R4. Rationale: case repair is a display concern, applied only on both of the parsed name's views: a middle revised to "e-f" repairs to "E-F" as the parsed name does, where splicing the same text in gives "e-F". - history: decisions/R4 · interacts: P3, R2, R3, R5, S3, S6 · implemented: nameparser/_render.py, nameparser/_lexicon.py + history: decisions/R4 · interacts: P3, R2, R3, R5, S3, S9 · implemented: nameparser/_render.py, nameparser/_lexicon.py diff --git a/docs/design/rules/R5-case-repair-gate.md b/docs/design/rules/R5-case-repair-gate.md index 7f6d5c9c..68b0617c 100644 --- a/docs/design/rules/R5-case-repair-gate.md +++ b/docs/design/rules/R5-case-repair-gate.md @@ -34,5 +34,5 @@ R5. Rationale: mixed case is evidence that the writer cased the name "john smith EdD" → capitalized="John Smith EdD" "juan garcia Iii" → capitalized="Juan Garcia Iii" · boundary "john smith EdD" → capitalized_forced="John Smith EDD" - history: decisions/R5 · interacts: R4, P3, S2 · implemented: nameparser/_render.py + history: decisions/R5 · interacts: R4, P3, S6 · implemented: nameparser/_render.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 37aff6ce..5290a79f 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 @@ -1,9 +1,9 @@ # S4 — the ambiguous acronym needs words to spare S4. Rationale: an acronym that is also an ordinary name (MA, Ma) - can be given up to the credential reading only where the name - keeps a word for its family; a name of two words has none to - spare. + is given up to the credential reading by position only where + the name keeps a word for its family; a name of two words has + none to spare. A BARE ambiguous acronym is consumed only when the name has words to spare — as the second of two words it stays the family name — and at the slots that report, either @@ -19,8 +19,9 @@ S4. Rationale: an acronym that is also an ordinary name (MA, Ma) states the case where a clause does it. The count is one piece 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 (S6), and an unambiguous credential in - front outranks both (S7). + in a mixed-case name everywhere but the first slot after a + comma, where the count decides first (S6, C1), and an + unambiguous credential in front outranks both (S7). "John Ma" → family="Ma" · boundary "wang meng" → family="meng" · boundary "tran lac" → family="lac" diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index 1b6f01f6..7de12d31 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -655,7 +655,7 @@ def test_a_suffix_written_in_more_than_one_case_is_kept_as_written( def test_the_gate_and_the_parser_read_a_cased_suffix_differently() -> None: """decisions/R5's split: the parser's own one-case readings - (rules/P3, #S2) still count a cased suffix, while R5's gate + (rules/P3, rules/S6) still count a cased suffix, while R5's gate leaves it out. So 'john e jones III' is MIXED to the parser, which reads its 'e' as a connective, and one-case to the gate, which repairs it -- keeping the connective lowercase -- where the one-case From 36ab52b15bbab36f4579dcdee980d477df2f2e73 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 17 Oct 2026 22:19:51 -0700 Subject: [PATCH 4/5] Record the citation spellings an ID split has to retarget (#682) 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 ddb3c19e..1979ae59 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`). +**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. **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 36b86a160fff16aacf4e4c761eb082ad9cd56eaa Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Sat, 17 Oct 2026 22:27:44 -0700 Subject: [PATCH 5/5] Fix the PR review findings on the S2 split (#682) decisions/S3's closing note no longer says S3 was part of rules/S2 (it only shared S2's log), and decisions/S2's note says so too. The test_design_docs fixture now pairs the case-signal name with S6, as the tree does, and probes S99 rather than the now-real S9 as missing. Two comments covering both the count and the case cite S4 and S6; test_render's #S2 becomes decisions/S6, where the judged-token decision lives; two comments the retarget overflowed are rewrapped. Co-Authored-By: Claude Opus 5.5 --- ...-ends-a-name-and-never-opens-the-string.md | 2 +- ...edentials-written-with-interior-periods.md | 2 +- nameparser/_pipeline/_classify.py | 7 +++--- nameparser/_pipeline/_pieces.py | 4 ++-- nameparser/_types.py | 3 ++- nameparser/config/suffixes.py | 2 +- tests/v2/test_design_docs.py | 22 +++++++++---------- tests/v2/test_render.py | 2 +- 8 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md b/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md index 8a963fc9..c7a4971e 100644 --- a/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md +++ b/docs/design/decisions/S2-suffix-word-ends-a-name-and-never-opens-the-string.md @@ -22,4 +22,4 @@ DECIDED (Derek): retiring the two reader-side mechanisms is worth the moves -- every one is garbage input, now read consistently, and none is a released reading restored. MEASURED 2026-10-10 against 505d6dec (#632's merge), py3.11, `nameparser.__file__` asserted on each side. A particle-run grid -- `{head} {run} {tail}` for every head in (none), `Dr.`, `Freiherr`, `St`, `John`, `Dr. John`, `Freiherr von`, `John Smith`, `Sir`, `Freiherr St`; every run of one to three words over `van der de la von und mc Mc vd do Do St du`; every tail in (none), `Berg`, `MA`, `PhD`, `Ma`, `Jr.`, `Berg MA`, `Prof.`, `Berg Ph. D.`, `V`, `VI`, `X.Y.Z.`, `née Smith`, `Berg Jr. PhD`, `Berg van`, `, Jr.`, `, John` (a comma tail written against the run) -- 395,148 texts, 1,185,444 parses under the three orders, comparing the seven fields and every report (kind, detail, tokens): 774 role moves (258 texts under each order), no report-only move. Every move holds `St und`, the joined piece carrying the title tag, which master merged into the particle run in front with the tag kept and read as a title (`Freiherr Do St und Berg MA` → title 'Freiherr Do St und Berg', family 'MA'); now title 'Freiherr', family 'Do St und Berg', suffix 'MA', the name part read alike with and without the trailing 'MA'. Not a released reading: 1.4.0 reads first 'Do St und Berg', last 'MA', and 2.3.0 family 'Do St und Berg MA', and over the 258 moved texts under the default order master's seven fields equal 2.3.0's on 152, the tree's on none. The S2 stress grid (#614's recipe, above): no role move, and one text's report under its four policies -- `de Mesnil Ma do mc and Ph. D.` no longer reports 'do': P3 joins `mc and Ph. D.` into one suffix-tagged piece, the merge folds 'do' into it, and the peel takes the piece whole rather than picking 'do', the report adf6da88 did not make either and #620 added (its entry above). The #624 title-particle head grid (decisions/P2, 2026-10-10): 0. Every corpus name and case text, and #624's comma-tail grid, under the three orders: 0. The five differential gates exit 0, their reports identical line for line to the tree before. The 2026-10-08 prototype's 80 moves (`Dr. mc mc`) are not in the population: #624 already reads `Dr. Mc Mc` as H3 states. Recorded negative controls (2026-10-10): with the merge off, 30 tests in tests/v2 fail (32 over the whole suite, measured at the review's fix), `test_an_absorbed_particle_run_reports_as_the_one_word_the_chain_makes` among them; with the report keyed on `j > k + 1` alone, the case rows `a_particle_takes_the_dual_behind_it_at_the_head` and `a_particle_run_before_the_run_stays_one_name_word`; with the merge starting at the segment's second piece rather than past the lead, 13; with `title` kept, only `test_a_merged_particle_run_is_a_particle_and_no_title`, the unit test added for it. Cost: +3 frames on the reference row, which holds `de la` (decisions/parse-cost, this date). THE REVIEW (2026-10-10) found the change reaching further, every shape garbage, none given a row (Derek): no move goes the other way, and nothing moved over comma shapes (an empty or nickname-only head, a family comma's given part), connective-separated runs, runs beside a maiden marker or holding a nickname, bound given names, one-case names, or caller lexicons adding title or suffix words as particles. (1) The `St und` class is any P3 join whose left word is in both the title and particle vocabularies, `und`, `and` or `of` (`y` moves nothing: master already reads `Freiherr Do St y Berg MA` as now), a caller's title-particle included (`Freiherr Do Freiherr and Berg MA`: master title 'Freiherr Do Freiherr and Berg', family 'MA'; now title 'Freiherr', family 'Do Freiherr and Berg', suffix 'MA'). (2) A title-particle head, a particle run, then a title and a dotted word (`Freiherr van der Dr. G.J. MA`, 88 texts on its grid under each order): the run now counts as one name word, so 'G.J.' has none to spare, the read takes nothing and group's own chain takes 'Dr. G.J.' -- family 'van der Dr. G.J.', suffix 'MA', as the one-particle `Freiherr van Dr. G.J. MA` reads (master title 'Freiherr Dr.', family 'van der', suffix 'G.J. MA'). (3) H3's give-back behind a leading title where every word is suffix vocabulary, the last two a run of duals (`Dr. vd Jr. mc mc`, about 1,800 parses over two grids): the merged run is a particle piece and no suffix word, so the title stays -- title 'Dr.', family 'vd', suffix 'Jr. mc mc', where master gave 'Dr.' back as the given name with 'vd' still standing; `Dr. vd Jr. mc` still gives it back. The review also found two runs in one name unreached by any test (an off-by-one in the merge's bookkeeping passed the suite), now the rows `two_particle_runs_merge_each` and `a_second_run_of_duals_is_the_family` (parity) and a half of the merge's unit test, which also holds that a lone particle is no merged run; and the read's title run taken before the merge held only by the frame bands, now `test_the_read_takes_the_title_run_as_written_before_the_merge` (`Dr. mc Jr mc mc`, which with the run asked again after the merge, by the read or by group, reads family 'mc Jr mc mc'; the fix's own review found the group-side half unheld by a read-level test, so it pins the parse too). -- 2026-10-17 #682 — THE RULE WAS SPLIT, AND SO WAS THIS LOG. rules/S2 had grown to seven rules, and each now has its own ID and its own decisions entry, which took the entries written under this key about it: decisions/S3 (the roman-chunk retirement and the digit-dotted tokens), decisions/S4 (the count and the slots that report), decisions/S5 (the given part's trailing slot after a family comma, #531, #573, #578), decisions/S6 (written case, #289, with the review and measurement rounds of the #289 + #516 arc), decisions/S7 (company, #544), decisions/S8 (the credential's run, #602, #627, #679) and decisions/S9 (the by-shape class, #516's two switches, #563, #564). What stays here is the trailing read itself (#614, #620, #625), which rules/S2 kept. The entries keep their text as written: one dated before this entry that says rules/S2 means the rule as it then stood. +- 2026-10-17 #682 — THE RULE WAS SPLIT, AND SO WAS THIS LOG. rules/S2 had grown to seven rules, and each now has its own ID and its own decisions entry, which took the entries written under this key about it: decisions/S3, which shared this log (the roman-chunk retirement and the digit-dotted tokens), decisions/S4 (the count and the slots that report), decisions/S5 (the given part's trailing slot after a family comma, #531, #573, #578), decisions/S6 (written case, #289, with the review and measurement rounds of the #289 + #516 arc), decisions/S7 (company, #544), decisions/S8 (the credential's run, #602, #627, #679) and decisions/S9 (the by-shape class, #516's two switches, #563, #564). What stays here is the trailing read itself (#614, #620, #625), which rules/S2 kept. The entries keep their text as written: one dated before this entry that says rules/S2 means the rule as it then stood. diff --git a/docs/design/decisions/S3-credentials-written-with-interior-periods.md b/docs/design/decisions/S3-credentials-written-with-interior-periods.md index a9c67d26..9f239eb9 100644 --- a/docs/design/decisions/S3-credentials-written-with-interior-periods.md +++ b/docs/design/decisions/S3-credentials-written-with-interior-periods.md @@ -2,4 +2,4 @@ - 2026-09-15 (Derek) — THE ROMAN-CHUNK ACCIDENT RETIRES, NARROWLY. rules/S3's chunk rule survives except where every chunk the vocabulary matches is a SINGLE ASCII CHARACTER — the accident exactly. Measured, `{w for w in (L.suffix_acronyms | L.suffix_words) if len(w) == 1}` is `{i, v, 2}` plus the glued CJK honorific tails `{様, 殿, 氏, 군, 님, 씨, 양}`, so "single ASCII character" names the first three and only those — CHARACTER because `2` is a digit and is in the set, ASCII because `씨` is the one that must KEEP its chunk claim (`J.씨`). So `John Smith R.A.I.` and `John Smith J.u.n.i.o.r.` reach the shape class and read suffix by POSITION with the same fields plus the report, `Jack X.Y.I.` reads family where it read suffix (1.4.0's reading), and `Msc.Ed.`, `JD.CPA`, `Lt.Gov.`, `J.씨` keep their chunk-derived readings. The WIDE retirement — any chunk match yielding to the shape — was MEASURED AND REJECTED: it moves three more corpus names, `Doe, John Msc.Ed.` losing a real credential to `middle` and the two `김민준씨, J.씨` rows re-routing their honorific peel, for nothing this design wants. - 2026-09-18 — DIGIT-DOTTED TOKENS MOVE, SILENTLY, AND IT IS ACCEPTED. The narrow retirement's set of single-ASCII-character suffix entries is `{i, v, 2}` — the roman numerals AND the lone digit — so a dotted token whose only chunk match is that digit loses the chunk claim, and the SHAPE class cannot take it either: `period_joined_vocab`'s shape verdict wants every chunk `isalpha()`, which no digit is. Measured against 1f78bef: `John Smith 1.4.2` suffix `1.4.2` → family `1.4.2`; `Smith, 1.4.2` family `Smith`/suffix `1.4.2` → given `1.4.2`/family `Smith`; `John Smith, 1.4.2` given `John`/family `Smith`/suffix `1.4.2` → given `1.4.2`/family `John Smith`. No report at any policy, and the switch does not reach it either way. ACCEPTED: a version string read as a credential was the same accident the retirement removes, and the three readings are pinned as case rows. `John Smith 1.4` was already family at 1f78bef and is unaffected — two chunks, neither a match. docs/release_log.rst's wording is corrected from "a single-character roman numeral" to the digit-inclusive form, which is what the code tests. -- 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. +- 2026-10-17 #682 — SPLIT FROM decisions/S2. rules/S3 was already a rule of its own, but its history pointed at decisions/S2, so the entries above were written there; when #682 split S2 they moved here unchanged but for pointers the move broke. diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 79f049ed..9017b519 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -179,9 +179,10 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, # else ANY suffix chunk makes it a suffix ('JD.CPA'). Title wins # (v1's continue). The third and fourth are 2.4's by-shape halves # (#516; rules/S9: "An unlisted word joins the ambiguous class - # (S4) by SHAPE where the caller asks for it") and reach only a token NO vocabulary claimed: a dotted - # token of two or more alphabetic chunks, and -- where its switch - # is on -- an unlisted all-caps word. They are an `elif` chain + # (S4) by SHAPE where the caller asks for it") and reach only a + # token NO vocabulary claimed: a dotted token of two or more + # alphabetic chunks, and -- where its switch is on -- an unlisted + # all-caps word. They are an `elif` chain # with the dotted one first, so a dotted token never reaches the # caps test and the two shapes stay disjoint. # diff --git a/nameparser/_pipeline/_pieces.py b/nameparser/_pipeline/_pieces.py index abfa9f27..e231cabb 100644 --- a/nameparser/_pipeline/_pieces.py +++ b/nameparser/_pipeline/_pieces.py @@ -595,8 +595,8 @@ def _anchors(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: # rules/S7: "Only a credential in FRONT speaks; one behind the # member says nothing about it." #544: an ambiguous member standing -# BEHIND an unambiguous credential in one run is described by that credential's company, not by its -# own writing -- 'PhD MEng' is a list of degrees whatever case 'MEng' +# BEHIND an unambiguous credential in one run is described by that +# credential's company, not by its own writing -- 'PhD MEng' is a list of degrees whatever case 'MEng' # is written in. ONE forward pass per question, so a run of members # is read in linear time: a look-behind per member is quadratic in # the run, which test_benchmark's frame-ratio guard is built to catch. diff --git a/nameparser/_types.py b/nameparser/_types.py index 5a8d3cf0..c31e5a5d 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -436,7 +436,8 @@ class AmbiguityKind(StrEnum): #: family name remains; "JACK MA", written wholly in one case, keeps #: it as the name because none would, while in a mixed-case name #: the capitals decide outright -- "Jack MA" is the credential and - #: "Jack Ma" the name, rules/S6) and a trailing roman numeral, which is a suffix where any + #: "Jack Ma" the name, rules/S4 and rules/S6) and a trailing roman + #: numeral, which is a suffix where any #: other single letter would be a name ("John Smith V" vs "John #: Smith B"). It also covers a family-comma listing, where a #: trailing abbreviation that is both a post-nominal and a surname diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 39eb130c..2042d518 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -342,7 +342,7 @@ # written in capitals inside a mixed-case name; inside a # mixed-case name any other cased form that is not wholly lower # ('Ma', 'MEng') reads as the name even with words to spare -- - # rules/S6. The parse reports the fork where it decides one, + # rules/S4 and rules/S6. The parse reports the fork where it decides one, # so 'Jack Ma' keeps its family name. # # The other half of the criterion, added 2026-09-07 with #342. diff --git a/tests/v2/test_design_docs.py b/tests/v2/test_design_docs.py index 6f113d60..fd198656 100644 --- a/tests/v2/test_design_docs.py +++ b/tests/v2/test_design_docs.py @@ -12,7 +12,7 @@ from tests.v2.rules_doc import parse_rules_doc _FIXTURE_FILES = ("S-suffixes.md", "S1-x.md", - "S2-case-signal-at-the-suffix-slot.md", + "S6-case-signal-at-the-suffix-slot.md", "vocabulary-collisions.md") @@ -22,8 +22,8 @@ def folder(tmp_path: Path) -> Path: "# Index\n\n" "## [Suffixes (S)](S-suffixes.md)\n\n" "- [S1 — x](S1-x.md)\n" - "- [S2 — the case signal at the suffix slot]" - "(S2-case-signal-at-the-suffix-slot.md)\n\n" + "- [S6 — the case signal at the suffix slot]" + "(S6-case-signal-at-the-suffix-slot.md)\n\n" "## Named topics\n\n" "- [vocabulary-collisions — when a word earns the ambiguous " "marking](vocabulary-collisions.md)\n", @@ -46,8 +46,8 @@ def test_slugify_follows_the_spec() -> None: def test_stem_for_names_rules_and_keeps_slugs() -> None: - assert stem_for("S2", "the case signal at the suffix slot") == ( - "S2-case-signal-at-the-suffix-slot") + assert stem_for("S6", "the case signal at the suffix slot") == ( + "S6-case-signal-at-the-suffix-slot") assert stem_for("S", "Suffixes") == "S-suffixes" assert stem_for("vocabulary-collisions", "when a word …") == ( "vocabulary-collisions") @@ -56,9 +56,9 @@ def test_stem_for_names_rules_and_keeps_slugs() -> None: def test_read_index_keeps_order_and_keys(folder: Path) -> None: entries = read_index(folder) assert [e.key for e in entries] == [ - "S", "S1", "S2", "vocabulary-collisions"] - assert entries[2].title == "S2 — the case signal at the suffix slot" - assert entries[2].path == folder / "S2-case-signal-at-the-suffix-slot.md" + "S", "S1", "S6", "vocabulary-collisions"] + assert entries[2].title == "S6 — the case signal at the suffix slot" + assert entries[2].path == folder / "S6-case-signal-at-the-suffix-slot.md" (folder / "README.md").write_text( "- [differential-ledger — the differential ledger's arcs]" "(differential-ledger.md)\n", encoding="utf-8") @@ -90,7 +90,7 @@ def test_mention_re_reads_a_key_wrapped_at_its_hyphen() -> None: @pytest.mark.parametrize("text", [ - "docs/design/rules/S2-case-signal-at-the-suffix-slot.md", + "docs/design/rules/S6-case-signal-at-the-suffix-slot.md", "decisions/README.md", "rules/README.md", "docs/design/rules/", @@ -104,8 +104,8 @@ def name(cid: str) -> str | None: p = resolve("rules", cid, folder) return p.name if p else None assert name("S") == "S-suffixes.md" - assert name("S2") == "S2-case-signal-at-the-suffix-slot.md" - assert name("S9") is None + assert name("S6") == "S6-case-signal-at-the-suffix-slot.md" + assert name("S99") is None assert name("README") is None assert resolve("decisions", "vocabulary-collisions", folder) == ( folder / "vocabulary-collisions.md") diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index 7de12d31..1813f875 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -655,7 +655,7 @@ def test_a_suffix_written_in_more_than_one_case_is_kept_as_written( def test_the_gate_and_the_parser_read_a_cased_suffix_differently() -> None: """decisions/R5's split: the parser's own one-case readings - (rules/P3, rules/S6) still count a cased suffix, while R5's gate + (rules/P3, decisions/S6) still count a cased suffix, while R5's gate leaves it out. So 'john e jones III' is MIXED to the parser, which reads its 'e' as a connective, and one-case to the gate, which repairs it -- keeping the connective lowercase -- where the one-case