diff --git a/AGENTS.md b/AGENTS.md index 6b8606fc..b79300dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Counting claims.** A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name. -**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the differential gate's classified summary, be verified against rules.md examples, or -- for a view the gate cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. The classified summary covers the CONTRACT tier plus whatever radar diffs a rule classifies; a radar corpus's unmatched diffs are listed under UNCLASSIFIED (radar) and are not in it, so a claim quantified from the summary alone is silent about them. The gate compares the seven role fields, `_ambiguities` and -- since #484 -- `initials()`, under the `_initials` pseudo-field; so an initials-only change DOES show in the classified summary. But that pseudo-field sees only the names whose initials moved WHILE EVERY ROLE STAYED PUT -- main()'s roles-identical guard keeps it out of any diff a role is already in -- so a count taken from it is a FLOOR on initials movement, not the population: measured 2026-09-02 at 2.1.0 → tree on the v2 surface, 83 of the 1120 compared ENTRIES (1116 distinct names; seven entries carry a declared order rather than the default, and three strings are compared under more than one) changed their `initials()` string and only 28 were visible under `_initials`, the other 55 having moved a role as well. A bullet about how many names' initials changed still needs the recompute recipe. `capitalized()` and any other render view stay invisible to the gate (decisions.md#R4, #R3), and for those the first two sources still cannot reach a claim: a gate run is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. Per-rule ledger toml comments asserting PARSER behavior cite rule IDs under the excerpt discipline; free prose is for ledger mechanics only (owned by tools/differential/README.md). +**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the differential gate's classified summary, be verified against rules.md examples, or -- for a view the gate cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. The classified summary covers the CONTRACT tier plus whatever radar diffs a rule classifies; a radar corpus's unmatched diffs are listed under UNCLASSIFIED (radar) and are not in it, so a claim quantified from the summary alone is silent about them. The gate compares the seven role fields, `_ambiguities` and -- since #484 -- `initials()`, under the `_initials` pseudo-field; so an initials-only change DOES show in the classified summary. But that pseudo-field sees only the names whose initials moved WHILE EVERY ROLE AND EVERY AMBIGUITY KIND STAYED PUT -- main()'s roles-identical guard keeps it out of any diff a role or a report is already in -- so a count taken from it is a FLOOR on initials movement, not the population: measured 2026-09-02 at 2.1.0 → tree on the v2 surface, 83 of the 1120 compared ENTRIES (1116 distinct names; seven entries carry a declared order rather than the default, and three strings are compared under more than one) changed their `initials()` string and only 28 were visible under `_initials`, the other 55 having moved a role as well. A bullet about how many names' initials changed still needs the recompute recipe. `capitalized()` and any other render view stay invisible to the gate (decisions.md#R4, #R3), and for those the first two sources still cannot reach a claim: a gate run is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. Per-rule ledger toml comments asserting PARSER behavior cite rule IDs under the excerpt discipline; free prose is for ledger mechanics only (owned by tools/differential/README.md). **Working on docs/design/ has its own AGENTS.md.** `docs/design/AGENTS.md` carries the landing-a-design distillation checklist, the primary-source review rule, the dated-count convention, and the review axes. Claude Code loads it automatically when a session reads or edits anything under docs/design/; if your tool does not do nested discovery, read it yourself before touching those files or reviewing a change to them. @@ -263,7 +263,7 @@ The library has two layers: `nameparser/config/` (data) and `nameparser/parser.p **Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps its source order and never segments — the orthography names the convention, exactly as pure katakana does, with the divider carrying the signal instead of the script. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so `segment_scripts` has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so `김, 민준씨` reads today exactly as the spaced `김 민준씨` does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched on the differential's radar tier rather than pinned as contract. Its site is accordingly the name-bearing segment runs — `segments[:2]` under a family comma, and `segments[0]` as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (`김, 민준 지훈씨` peels; `김, 민준, 지훈씨` and `김,, 민준씨` do not, both landing in a third run), and whether `segments[1]` is name text at all is now ASKED rather than inferred from the structure — `segment` does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took `V.` for its site, found no listed tail and abandoned (#319). The question is `segment`'s own suffix-comma predicate, lifted into `_vocab.is_wholly_suffix` so the two stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in `segments[0]`, so `田中さん, V.` and `田中さん, Ph. D.` give さん up as `田中さん, PhD` always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. `segments[0]` must therefore offer a peel site of its own before the second run is declined — `이, J.씨` and `선생님, J.씨` pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while `김민준씨, J.씨` has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is `assign`'s question and still differs by spelling (`V.` → `given`, `PhD` and `Ph. D.` → `suffix`). Not `_is_post_nominal` pluralized: the run predicate says yes both to what the token predicate vetoes (`V.`, `V`, `I` — the class the defect was reported as) and to what the token predicate never sees at all, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` moves with the rest). `Policy(lenient_comma_suffixes=False)` drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while `Ph. D.` peels under the knob regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. -**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. +**A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`; 568 of 575 against seven, measured 2026-09-25 after #540), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. **Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, and a ONE-WORD name before the comma are three paths, and the third is the miss — #429, #430 and #432 are all that path disagreeing with the full-name path on inputs the full-name path reads correctly. For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. diff --git a/docs/customize.rst b/docs/customize.rst index c72342db..54430543 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -228,9 +228,9 @@ Words that are also ordinary names Three fields — ``suffix_acronyms_ambiguous``, ``particles_ambiguous`` and ``conjunctions_ambiguous`` — mark entries from ``suffix_acronyms``, ``particles`` and ``conjunctions`` that are also plausible as ordinary -name words on their own (an acronym suffix that doubles as a nickname, -a particle that doubles as a given name, a connective letter that -doubles as an initial). They don't add new vocabulary by themselves; +name words on their own (an acronym suffix that is also +borne as a name, a particle that doubles as a given name, a connective +letter that doubles as an initial). They don't add new vocabulary by themselves; they narrow how an existing entry is read when it appears alone. If you're not sure whether a word you're adding is one of these ambiguous cases, weigh how often it is a diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 348bf3f2..621a6b35 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -608,6 +608,9 @@ Closes #342 (a wordlist question) and #454 (a rules.md question) together, becau - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl, in three diff shapes with one cause: Aishwarya Rai moves {family, suffix}, Lala Lajpat Rai and John Smith RAI move {middle, family, suffix}, and John Smith, RAI and Ahmad Jayadi, CHA move {given, family, suffix}. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().add(suffix_acronyms={"rai","cha"})`, which RESTORES the two entries this bundle removed so the diff read forwards is the removal's — and diffing the seven name fields plus `ambiguities`; the ba step of the same sweep moves nothing. Aishwarya Rai is the one name whose diff DISAPPEARS at 1.4.0, that release having read family Rai too, which is why the `fix(#342) NOT WANTED` ledger rule was deleted rather than rewritten and why the 1.4.0 gate lists four names under the replacement where the three 2.x ledgers list five. Read today's intentional counts off the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`; they rose by three at 1.4.0 and by five at each 2.x baseline. - **2026-09-15 (#289/#516) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See #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-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.md#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. ### S2 — the case signal at the suffix slot @@ -1386,9 +1389,7 @@ Accepted costs, deferred to the rescoped #459 rather than relitigated here: the Excluded (CAPITALIZATION_EXCEPTIONS — meng, edd, lac, ded, left out of the 2026-09-24 masks, #459): -- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese and French surname) and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it. Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng`. `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. - -Open: a follow-up issue on `SUFFIX_ACRONYMS`'s `meng`/`lac` name-word collision is to be filed (the note above carries the measurements). +- `meng` (MEng) and `edd` (EdD) are conventionally mixed-case credentials and listed acronyms, and must not get a mask while the map stays role-free: a mask applies in every role, and both are borne as given names or surnames. Measured 2026-09-24 with each mask added to a private lexicon: `MENG LI` gives `MEng Li`, `meng wang` gives `MEng Wang`, `edd smith` gives `EdD Smith` and `EDD KIMBER` gives `EdD Kimber`, where the shipped map and every release give `Meng Li`, `Meng Wang`, `Edd Smith` and `Edd Kimber`. The same holds for two sweep candidates, `lac` (LAc, Licensed Acupuncturist; `Lac` is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; corrected 2026-09-25, #540, from "a Vietnamese and French surname") and `ded` (DEd; `Ded` is an Albanian given name, `ded gjo luli` giving `DEd Gjo Luli` under the mask). A writer who spells one mixed-case keeps it (decisions.md#R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. A NOTE on `SUFFIX_ACRONYMS` itself, which this map does not decide. The two name-word collisions there come from two bulk additions, not one: `lac` (with `edd` and `ded`) from the Wikipedia post-nominals import, `af5bdab` (2019-12-11, #93), and `meng` from `3e14ea20` (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). Both read as a suffix after a given name, and lose a family name to it (superseded 2026-09-25 by #540: both words are marked ambiguous, `wang meng` reads family `meng` and renders `Wang Meng` — see #suffix-acronym-collisions). Measured 2026-09-24 on the released wheels, the parent 4d0680e6 and this tree: `wang meng`, `li meng` and `zhang meng` read family `meng` on 1.4.0 and suffix `meng`, with no family, on every release from 2.0.0; and since #521 on master the suffix renders `Wang MENG` (parent and tree alike), where 2.3.0 gave `Wang Meng`. `nguyen van lac` reads suffix `lac` with family `van` on every release from 1.4.0, and since #521 renders `Nguyen Van LAC` where 2.3.0 gave `Nguyen Van Lac`. This is a #vocabulary-collisions C-i question about the acronym set, not about this map. Excluded (CAPITALIZATION_EXCEPTIONS — md, ii, iii, iv, removed 2026-09-23, #459): diff --git a/docs/design/rules.md b/docs/design/rules.md index 9ca3d42d..e0e88686 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1039,6 +1039,12 @@ S2. Rationale: generational suffixes and credentials are recognized "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." → family="M.Eng." "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" "Doe, John MA" → suffix="MA" diff --git a/docs/release_log.rst b/docs/release_log.rst index 43a553cf..5aba1ab3 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -12,6 +12,8 @@ Release Log - **Fix a credential acronym that is also a surname being read by position alone.** ``HumanName("Jack MA")`` gives suffix ``MA`` where 2.0 through 2.3 gave last ``MA``, and ``John Smith Ma`` gives last ``Ma`` where they gave suffix ``Ma``. In a name written in more than one case, an ambiguous acronym written in capitals is written the way a credential is written and is read as one even where removing it leaves no surname; one written in any other cased form that is not wholly lower is written the way a surname is written and stays one even where there are words to spare (``John Smith ma`` and ``John Smith ed`` -- all lower, no contrast -- give suffix ``ma``/``ed`` instead). A name written wholly in one case says nothing either way and keeps the reading it had: ``JOHN SMITH MA`` is still a credential, ``ANH DO`` still a surname, ``jack ma`` still a surname. The same reading reaches the comma forms, where the words-to-spare count is now a count of NAME words: ``Smith, MA`` gives last ``Smith``, suffix ``MA``; ``Smith Jr., MA`` keeps last ``Smith``; and ``John Smith, MA``, ``John Smith, Ed``, ``john smith, ma`` and ``JOHN SMITH, MA`` all give a suffix again, which is what 1.4.0 read and 2.0 through 2.3 did not. ``Jack Ma`` and ``Anh Do`` are unchanged. The LEAN is inert on a caseless script, but the comma count above is not -- it asks name-word count, not case -- so ``마틴 킹, MA`` and ``田中 太郎, MA`` also give a suffix again (1.4.0 parity on the suffix, two pre-comma name words each) while the single-token ``毛泽东, MA`` does not move, having no case to write a contrast in either way. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #289) + - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name: meng and lac are now acronyms that are also ordinary names.** ``HumanName("wang meng")`` gives first ``wang``, last ``meng``, and ``parse()`` reports a suffix-or-name ambiguity, where every release from 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name; 1.4.0 read last ``meng``, so this is 1.4.0's answer plus the flag. ``li meng`` and ``tran lac`` move the same way, ``Wang, Meng`` gives first ``Meng``, last ``Wang`` again, and ``Parser(policy=Policy(name_order=FAMILY_FIRST)).parse("Wang Meng")`` gives given ``Meng`` where 2.0.0 through 2.3.0 gave family ``Wang``, suffix ``Meng`` and no given name. With a full name in front the credential reading stays: ``john smith meng`` and ``nguyen van lac`` keep suffix ``meng`` and ``lac``, now flagged. But a Title-case ``Nguyen Van Lac`` gives last ``Van Lac`` where every release gave last ``Van``, suffix ``Lac``. The cost is the marking's own, and it falls on the conventional spellings: a ``MEng`` or ``LAc`` written that way, in a name written in more than one case, is read the way ``John Smith Ma`` is (above), so ``John Smith MEng`` gives middle ``Smith``, last ``MEng``, where every release gave suffix ``MEng``. A credential run ending in one of them goes the same way: ``Mary Jones PhD MEng`` gives middle ``Jones PhD``, last ``MEng``, where every release read the whole run as a suffix (``PhD, MEng`` through 2.2, ``PhD MEng`` in 2.3); ``John Smith MEng PhD`` gives middle ``Smith``, last ``MEng``, suffix ``PhD`` (``rules.md#S2``'s declined pick stops the walk, the cost ``Doe, John MA Ma`` already pays). ``Wang M.Eng.`` gives last ``M.Eng.``, where 2.0 through 2.3 gave suffix ``M.Eng.``: the period gate counts a member as unambiguous only written one period per letter, and ``M.Eng.`` is chunked; ``John Smith M.Eng.`` keeps the suffix. After a comma ``Smith, MEng`` and ``Smith, meng`` give first ``MEng`` and ``meng`` (1.4.0's reading, not the suffix 2.0 through 2.3 gave) and ``Smith, John MEng`` gives middle ``MEng`` (every release gave suffix ``MEng``), and a bracketed ``John Smith (MEng)`` falls through to nickname, where every release gave suffix ``MEng``. A credential run after a comma whose last word is one of them re-reads the comma as a family comma, whatever its letter case: ``John Smith, PhD MEng`` gives first ``PhD``, middle ``MEng``, last ``John Smith`` and ``john smith, phd meng`` gives first ``phd``, last ``john smith``, suffix ``meng``, where every release read the run as a suffix; a title-listed credential in front reads as a title instead (``Jane Doe, MS LAc`` gives title ``MS``, first ``LAc``, unflagged). The path is the one ``john smith, phd ma`` already takes. A lone credential after a comma behind a full name (``John Smith, MEng``) keeps the credential reading, and so does a second comma before a run that ends in one (``John Smith, PhD, MEng`` keeps suffix ``PhD, MEng``); writing it in capitals does not (``JOHN SMITH, PHD MENG`` still re-reads the comma). Meng is a common Chinese surname and given name, Lac a Vietnamese given name (``Nguyen Van Lac``) and a French surname; see the ``suffix-acronym-collisions`` entry of ``docs/design/decisions.md`` (closes #540) + - **New Policy field unlisted_dotted_suffixes, on by default: a dotted acronym nobody has listed is read by position.** ``HumanName("John Smith X.Y.Z.")`` gives suffix ``X.Y.Z.`` where every release gave last ``X.Y.Z.``, while ``Jack X.Y.Z.`` keeps its surname, the same words-to-spare rule a listed acronym takes -- and both readings are reported. Case is irrelevant here: the periods are the signal, so ``john smith x.y.z.`` reads the same way. Words the vocabulary does know are untouched (``M.A.``, ``Ph.D.``, ``A.B.C.``), a single trailing period is still not this shape (``John Smith Xyz.`` keeps last ``Xyz.``), and a dotted run at the FRONT of a name is untouched (``J.R.R. Tolkien``). One accident retires with it: a dotted word whose only vocabulary matches were SINGLE ASCII CHARACTERS -- the roman numerals the suffix list holds, and the lone digit ``2`` -- was reading as a generational suffix, so ``Jack X.Y.I.`` gives last ``X.Y.I.`` again, as 1.4.0 read it, while ``Msc.Ed.``, ``JD.CPA`` and ``Lt.Gov.`` are unchanged. The digit is why a dotted VERSION STRING moves with them and moves SILENTLY: ``John Smith 1.4.2`` gives last ``1.4.2`` where 2.3 gave suffix ``1.4.2``, and ``John Smith, 1.4.2`` gives first ``1.4.2``, last ``John Smith``. Such a token reports nothing at any policy -- it is no acronym either, the shape reading wanting every chunk alphabetic -- and a version string read as a credential was the same accident this retirement removes. That retirement is NOT behind this switch and stands either way -- setting it to ``False`` reads an unlisted dotted word as name material by position instead (``John Smith X.Y.Z.`` keeps last ``X.Y.Z.``), the pre-2.4 reading for THAT half alone. See the ``S2`` and ``suffix-acronym-collisions`` entries of ``docs/design/decisions.md`` (closes #516) - **New Policy field unlisted_caps_suffixes, off by default: an opt-in reading for an unlisted all-caps credential.** It reaches the core parser only -- ``Parser(policy=Policy(unlisted_caps_suffixes=True))`` -- since the field has no v1 ``Constants`` manager. With it on, ``.parse("John Smith XYZ")`` gives given ``John``, last ``Smith``, suffix ``XYZ``, and ``.parse("John Smith, XYZ")`` gives the same three fields. It is off by default because an all-caps surname is a real writing convention that shape cannot separate from a credential: ``Jean DUPONT``, ``Minjun KIM`` and ``Jean Pierre DUPONT`` are surnames in French and Korean records, and the last of those gives given ``Jean``, last ``Pierre``, suffix ``DUPONT`` with the switch on. Off, nothing changes and nothing is reported -- 1.4.0's reading for that whole class. Neither of the two new fields reaches the v1 ``Constants`` API, as ``lenient_comma_suffixes`` does not: a ``HumanName`` tracks the parser's own DEFAULTS, so the dotted reading above (default on) reaches it while this one (default off) cannot be turned on from there. See the ``S2`` entry of ``docs/design/decisions.md`` (closes #516) diff --git a/nameparser/config/suffixes.py b/nameparser/config/suffixes.py index 2afea5a3..ff9eb1e4 100644 --- a/nameparser/config/suffixes.py +++ b/nameparser/config/suffixes.py @@ -211,23 +211,24 @@ """ SUFFIX_ACRONYMS_AMBIGUOUS = frozenset({ # Suffix acronyms that also commonly work as given-name nicknames on - # their own (e.g. "Ed", "JD"). Two readers in 2.x, not the single v1 - # one this comment used to name: _extract._suffix_shaped, deciding - # whether parenthesized/quoted content is a nickname or a suffix - # (content matching one of these stays a nickname, the more common - # reading in ambiguous, delimiter-only context), and _vocab's - # suffix_as_written, which excludes the ambiguous subset from plain - # acronym membership so the period gate below is not dead code. - # _classify also tags membership as "vocab:suffix-ambiguous". + # their own (e.g. "Ed", "JD"). Read wherever the pipeline decides an + # acronym's bare reading (rules.md#S2) 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". # # When adding a new entry to SUFFIX_ACRONYMS, also add it here only if # the exact letter sequence could plausibly be someone's name on its # own -- a given name or nickname (e.g. 'jd', 'ed') or a common # surname (e.g. 'ma', 'do'). Unambiguous certifications/degrees # (e.g. 'mba', 'cpa', 'phd') don't need an entry. In 2.0 this set - # also gates bare recognition: an ambiguous acronym counts as a - # suffix only when written with periods ('M.A.' yes, 'Ma' no), so - # 'Jack Ma' keeps its family name. + # also gates bare recognition: a bare ambiguous acronym reads as + # the credential only with words to spare in front of it, or + # 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.md#S2. 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. # Being borne at all is only the entry ticket; what decides among @@ -248,7 +249,13 @@ # Where the CREDENTIAL dominates, the entry stays unambiguous. # LENGTH is a correlate and not the test -- a short acronym is # more often a common credential AND more often a name -- so do - # not read the letter counts here as a rule. + # not read the letter counts here as a rule. #540 (2026-09-25) + # marked 'lac' and 'meng', the first entries here longer than + # two letters: MEng and LAc are credentials people write after + # their names, and Meng and Lac are borne in the trailing slot + # by real people, and neither reading is rare enough to give the + # word to the other -- so the marking, whatever the letter + # count. # # Removal takes the DOTTED spelling with it too, except by # accident: "John Smith R.A.I." still reads suffix 'R.A.I.' only @@ -283,14 +290,20 @@ 'do', 'ed', 'jd', + # #540: LAc, Licensed Acupuncturist; Lac is a Vietnamese given name + # (the trailing word in native order, Nguyen Van Lac) and a French + # surname + 'lac', 'ma', + # #540: MEng, Master of Engineering; Meng, a Chinese surname and given name + 'meng', }) """ Acronym suffixes from SUFFIX_ACRONYMS that also plausibly collide with a -common given-name nickname. Not a partition of SUFFIX_ACRONYMS -- a small, -standalone exception list, read by the delimited-content escape in -``_pipeline/_extract.py`` and by ``_pipeline/_vocab.py``'s period gate. +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.md#S2). """ SUFFIX_ACRONYMS = frozenset({ diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 819b2c4a..7a900499 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -491,7 +491,8 @@ def _check_cjk_shape_purity(self) -> None: # decisions.md#suffix-acronym-collisions and it compares # FREQUENCIES: how common the word is as a borne name in the # trailing position against how common it is as a credential. - # Rough balance earns the ambiguous marking (ba, do, ed, jd, ma); + # Rough balance earns the ambiguous marking (the members of + # SUFFIX_ACRONYMS_AMBIGUOUS; the frozenset is the roster); # the name reading dominating REMOVES the entry, a caller adding # it back with Lexicon.default().add(suffix_acronyms={"cha"}); # the credential dominating leaves it unambiguous. Length is a @@ -590,6 +591,179 @@ def _check_cjk_shape_purity(self) -> None: "NOT move -- only the flag is new. The twin of " "ambiguous_acronym_is_a_suffix_when_a_family_name_remains " "above"), + # -- #540: 'meng' and 'lac' take the marking 'ba' took, on the same + # criterion (decisions.md#suffix-acronym-collisions). MEng and LAc + # are live credentials; Meng is a Chinese surname and given name, + # 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.md#S2's forks read through two more + # members, written all-lower so the count decides unless the + # writing says otherwise, and shape-tagged so the differential's + # contract tier sees them -- no corpus name had either word. + Case("bare_meng_is_a_surname", "wang meng", + {"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 " + "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 " + "fork, which was the wrong fork); 1.4.0 read family " + "'meng' unflagged, so this row restores 1.4.0's roles " + "and adds the flag. The twin of bare_ba_is_a_surname", + shape=1), + Case("bare_lac_is_a_surname", "tran lac", + {"given": "tran", "family": "lac"}, + ambiguities=("suffix-or-name",), + notes="the same fork for the other word, with the same " + "history: suffix 'lac' and no family from 2.0.0 " + "through 2.3.0, family 'lac' at 1.4.0", + shape=1), + Case("meng_is_a_suffix_when_a_family_name_remains", + "john smith meng", + {"given": "john", "family": "smith", "suffix": "meng"}, + ambiguities=("suffix-or-name",), + notes="the words-to-spare half: a full name in front leaves " + "the credential reading available, and a name written " + "wholly in one case says nothing about any word in it, " + "so the count decides. The role does NOT move -- every " + "release read suffix 'meng' -- only the flag is new", + shape=1), + Case("lac_is_a_suffix_when_a_family_name_remains", + "nguyen van lac", + {"given": "nguyen", "family": "van", "suffix": "lac"}, + ambiguities=("suffix-or-name",), + notes="the words-to-spare half for 'lac', read by every " + "release since 1.4.0 as it is here; only the flag is " + "new. The Title-case 'Nguyen Van Lac' does NOT read " + "this way: in a name written in more than one case a " + "Title-case member is the name even with words to " + "spare, so it gives family 'Van Lac' -- the same " + "mechanism the next row pins for 'MEng', though for " + "this Vietnamese name it is the gain: a person, not " + "a credential", + shape=1), + Case("mixed_case_meng_after_a_full_name_reads_as_the_name", + "john smith MEng", + {"given": "john", "middle": "smith", "family": "MEng"}, + classification="fix(#540)", + 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 " + "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 " + "credential on the capitals lean", + shape=1), + Case("comma_mixed_case_meng_reads_as_a_middle_name", + "Smith, John MEng", + {"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 " + "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 " + "release from 1.4.0 read suffix 'MEng'. 'John Smith, " + "MEng' keeps the suffix on C1's name-word count", + shape=2), + Case("comma_lone_meng_reads_as_the_given_name", "Smith, meng", + {"given": "meng", "family": "Smith"}, + 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 " + "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 " + "family 'Smith', suffix 'meng'; 1.4.0 read given " + "'meng', so this row restores 1.4.0's reading and " + "adds the flag", + shape=2), + Case("comma_lone_mixed_case_meng_reads_as_the_given_name", + "Smith, MEng", + {"given": "MEng", "family": "Smith"}, + ambiguities=("suffix-or-name",), + notes="the same path for the conventional spelling, which " + "is the cost side of it: a writer who meant the " + "credential gets given 'MEng' instead, where 'Smith, " + "MENG' keeps the suffix on the capitals lean and " + "'Smith, John MEng' " + "(comma_mixed_case_meng_reads_as_a_middle_name) reads " + "it as a middle name. 1.4.0 read given 'MEng' too, so " + "this row also restores 1.4.0's reading", + shape=2), + Case("comma_credential_run_ending_in_meng_re_reads_the_comma", + "John Smith, PhD MEng", + {"given": "PhD", "middle": "MEng", "family": "John Smith"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the run behind a suffix comma is no longer wholly " + "suffix-shaped once its last word is a bare ambiguous " + "member, so C1 reads the comma as a family comma and " + "the first credential as the given name. Every " + "release read suffix 'PhD MEng' -- the pre-existing " + "'john smith, phd ma' path, which #540 routes two " + "more words into; #544 asks whether to keep the comma", + shape=3), + Case("comma_lower_credential_run_ending_in_meng_re_reads_the_comma", + "john smith, phd meng", + {"given": "phd", "family": "john smith", "suffix": "meng"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the same re-read in one case -- the count then " + "peels 'meng' with a word to spare, so the writing's " + "case does not save it, and neither does the " + "all-caps 'JOHN SMITH, PHD MENG' (given 'PHD', " + "family 'JOHN SMITH', suffix 'MENG')", + shape=3), + Case("title_case_lac_behind_a_particle_is_the_name", + "Nguyen Van Lac", + {"given": "Nguyen", "family": "Van Lac"}, + 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 " + "Vietnamese name; every release from 1.4.0 read " + "family 'Van', suffix 'Lac'", + shape=1), + Case("the_accepted_cost_survives_a_credential_behind_it", + "John Smith MEng PhD", + {"given": "John", "middle": "Smith", "family": "MEng", + "suffix": "PhD"}, + classification="fix(#540)", + ambiguities=("suffix-or-name",), + notes="the declined pick stops the walk, and the " + "unambiguous credential behind it is still peeled; " + "every release read suffix 'MEng PhD' ('MEng, PhD' " + "through 2.2)", + shape=1), + Case("dotted_meng_with_nothing_to_spare_is_the_name", + "Wang M.Eng.", + {"given": "Wang", "family": "M.Eng."}, + ambiguities=("suffix-or-name",), + notes="S2's period gate counts a member as unambiguous " + "only written one period per letter ('M.A.'), and " + "MEng and LAc are the first members whose " + "conventional dotted spelling is chunked, so " + "'M.Eng.' with nothing to spare is the family name; " + "accepted and recorded (Derek, 2026-09-25). 1.4.0 " + "read the same family, unflagged, for a different " + "reason -- its two-piece rule (a lone word after the " + "given name is the family), not S2's gate -- so this " + "row is parity. 'John Smith M.Eng.' keeps the suffix " + "by the count and reports; the period-gate question " + "goes to #544", + 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 " + "reports from, and a leading word is none of them. No " + "report, and every release read it this way", + shape=1), Case("by_design_trailing_mc_reads_as_a_credential", "Donald Mc", {"given": "Donald", "suffix": "Mc"}, classification="fix(suffix-routing)", @@ -3061,7 +3235,7 @@ def _check_cjk_shape_purity(self) -> None: {"given": "John", "family": "Doe", "suffix": "BA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), - notes="'ba' is another listed member (one of the five " + notes="'ba' is another listed member (one of those " "decisions.md#suffix-acronym-collisions marked " "ambiguous rather than removing), so the slot is not a " "rule about 'ma' -- it is the whole class", diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py index f76a1638..d34e6001 100644 --- a/tests/v2/test_ledger_guards.py +++ b/tests/v2/test_ledger_guards.py @@ -1209,6 +1209,39 @@ def test_case_shape_ids_exist_in_the_inventory() -> None: "change(suffix-acronym-collisions) ph leaves the acronym set": ("John Smith Ph. D.", "Smith, Ph. D.", "john smith phd", "John Smith Ph.D."), + # #540's eight rules, keyed on the full issue since all eight + # carry `fix(#540)`. Each wall is the other rules' names plus the + # spellings a case-blind widening would reach: 'meng li' leads + # with the word and nothing moved; the one-case 'john smith meng' + # and 'JOHN SMITH MENG' keep the credential, so the cost rule has + # no business with either. Its third probe, 'John Smith MENG', is + # not one-case either -- MENG's own capitals keep the credential + # on the capitals lean, in a name otherwise written Title-case. + # The report rule must not reach the names whose ROLES move, and + # the comma-cost rule must not reach the bare-word, no-comma or + # all-lower spellings. The lone-word comma rule must not reach + # its own capitals-lean probe or the two-word comma rule's name. + # Every #540 rule carries one superstring probe too, after a + # mutant dropping the ^...$ anchors passed every other guard here. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + ("meng li", "Meng Li", "john smith meng", "john smith MEng", + "Dr. wang meng"), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + ("john smith meng", "JOHN SMITH MENG", "John Smith MENG", + "Dr. john smith MEng"), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + ("wang meng", "tran lac", "john smith MEng", "meng li", + "Dr. john smith meng"), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + ("John Smith, MEng", "Smith, John meng", "Dr. Smith, John MEng"), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + ("Smith, MENG", "Smith, John MEng", "Dr. Smith, meng"), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + ("John Smith, MEng", "John Smith, PhD", "Dr. John Smith, PhD MEng"), + "fix(#540) a Title-case Lac behind a particle is the family name": + ("nguyen van lac", "Dr. Nguyen Van Lac"), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + ("John Smith M.Eng.", "Wang M.A.", "Dr. Wang M.Eng."), # The esq boundary is every spelling SUFFIX_WORDS still carries, # in each of the three positions the corpora write it in. "change(suffix-acronym-collisions) esq leaves the acronym set": @@ -2418,20 +2451,45 @@ class _LatinCopy(NamedTuple): # words; the same reason as fix(#400)'s pair above. frozenset({"^", "\\s"}), frozenset({"^", "[\\s,]"}), - # fix(#385/#402)'s 25 spellings: the 28 corpus names whose + # fix(#385/#402)'s spellings: the corpus names whose # all-particle part moved, listed because "a part of nothing but # particles" is not a property a regex over the raw string can # state. A list of names, the fix(#445) precedent -- that rule and # fix(#410) and fix(#335) are the 1.4.0 ledger's literal lists. - # 'anh van mc' joined 2026-09-18 with its case row. + # 'anh van mc' joined 2026-09-18 with its case row, and 'nguyen + # van lac' 2026-09-25 with #540's. frozenset({"anh do", "smith van der", "yin le", "yin a le", "vai la", "jong van der", "jong, van der", "juan van der", "mesnil garcia de", "mesnil garcia van", "mesnil de", "sander van", "van ma van", "anh van do", "anh van mc", "beethoven ludwig van", "berg jan de jr\\.", "john van mc", "jong anke de", "juan de", "ménil christophe de", - "ménil de", "nguyen thi van", "nguyen, van le", - "van berg jan de"}), + "ménil de", "nguyen thi van", "nguyen van lac", + "nguyen, van le", "van berg jan de"}), + # #540's two lists, one alternative per corpus name: the names + # whose family comes back and the names that keep the credential + # and gain a report. Lists of names, not copies of + # SUFFIX_ACRONYMS_AMBIGUOUS -- the rules' subject is which SLOT + # the member stands in, which no vocabulary decides, and a member + # spelled as the bare word would reach 'meng li' and + # 'john smith MEng'. + frozenset({"tran lac", "wang meng"}), + frozenset({"john smith meng", "nguyen van lac"}), + # #540's lone-word comma rule shares a literal prefix ('Smith, '), + # so its alternation is over the word's two spellings alone (the + # word reads as the given name here), not a full name pair like + # the two lists above -- 'meng'/'MEng' is not a copy of + # SUFFIX_ACRONYMS_AMBIGUOUS's bare 'meng' either, the same reason + # as the two lists above. + frozenset({"MEng", "meng"}), + # #540's second pair: the widened cost rule (a full name pair, + # the credential run behind it kept as a separate word) and the + # credential-run rule (a full name pair sharing no literal + # prefix, so its alternation IS over full names like the two + # lists above). Neither copies SUFFIX_ACRONYMS_AMBIGUOUS -- same + # reason as the rest of this roster. + frozenset({"John Smith MEng PhD", "john smith MEng"}), + frozenset({"John Smith, PhD MEng", "john smith, phd meng"}), # fix(#462)'s letter shape: a bare capital E/Y or a dotted E./Y. # It is the initial SHAPE (v1's `initial` regex, _render._INITIAL) # intersected with the single-letter conjunctions, not a copy of @@ -3181,6 +3239,22 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's four rules, the only #540 rules this baseline needs + # (every one of them either an accepted cost or the gain that + # is its mirror): the mixed-case credential read as the name + # even with words to spare, no-comma (now also the credential + # run behind it, widened in) and after a family comma; the + # credential run behind a suffix comma re-reading it as a + # family comma; and the Title-case Lac gain. Roles without + # `_ambiguities`, which 1.4.0 does not compare. + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(2, ('family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -3362,8 +3436,15 @@ def _claim(rule: dict) -> _Claim: # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); # its forced 'Dr. med. univ. Margit Popp, MSc' line was already # in corpus_issues.jsonl. Reach, verified name by name. + # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new + # comma-cost row's shape landing in this rule's reach too. + # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, + # MEng', the lone-word comma rows' shape landing here too. + # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and + # 'john smith, phd meng', the credential-run comma rows' + # shape landing here too. "fix(comma-family) lone post-comma piece routes to suffix/title, not first": - _Claim(366, ('given', 'suffix', 'title'), '0b1a15dd9b78', None), + _Claim(371, ('given', 'suffix', 'title'), 'af9e870177b7', None), "fix(comma-family) a comma followed only by titles keeps the given/family split": _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": @@ -3391,8 +3472,13 @@ def _claim(rule: dict) -> _Claim: # 2026-09-18, #531: 20 -> 21. One new corpus name, 'Doe, MA' -- # the family-word twin of the 'Smith, MA' above, and a lone # post-comma credential by this rule's own description. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: 1.4.0 already reads the given name + # for both, so there is no diff here to explain. "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('family', 'given', 'suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('family', 'given', 'suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -3423,8 +3509,15 @@ def _claim(rule: dict) -> _Claim: # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); # its forced 'Dr. med. univ. Margit Popp, MSc' line was already # in corpus_issues.jsonl. Reach, verified name by name. + # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new + # comma-cost row's shape landing in this rule's reach too. + # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, + # MEng', the lone-word comma rows' shape landing here too. + # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and + # 'john smith, phd meng', the credential-run comma rows' + # shape landing here too. "fix(comma-precomma-family) pre-comma run reads as family, not given": - _Claim(366, ('family', 'given'), '0b1a15dd9b78', None), + _Claim(371, ('family', 'given'), 'af9e870177b7', None), # 2026-09-20, #397: retitled in place, reach and digest # unchanged -- the rule keeps 'Carod i', which the landing # leaves byte-identical. @@ -3613,8 +3706,16 @@ def _claim(rule: dict) -> _Claim: # spelling of the fragment the corpora carry, and the trailing # ones are protected by the [[never]] entry above, which is # what _EXCLUSION_EFFECT's grown `absorbed_by` records. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', which #540's + # case row admitted and which this rule explains here: its + # family 'van' is a one-particle part, this rule's reason and + # not #540's. + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- reached, not + # explained, by this rule: its role move is explained by the + # Title-case Lac rule instead, so `_initials` never enters its + # diff here. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # #269's derived-view rule, added 2026-09-13. One corpus name, @@ -3916,6 +4017,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's eight rules: five literal alternations and three + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -4138,8 +4259,13 @@ def _claim(rule: dict) -> _Claim: # by this rule's own description. # 2026-09-18, #531: 20 -> 21, the same one new name # ('Doe, MA') as the 1.4 twin. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: at this baseline the diff moves + # `given`, a field outside this rule's own ('suffix', 'title'). "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -4192,12 +4318,20 @@ def _claim(rule: dict) -> _Claim: _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "fc6bc9e605e1", ('FAMILY_FIRST',)), "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling": _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "3e43a2be022e", ('FAMILY_FIRST_GIVEN_LAST',)), - # #484's two `_initials` rules. Both are literal name lists, so - # reach equals what they explain here -- 27 and 1 -- and both - # digests match the 1.4.0 ledger's, which is the point of - # copying the list verbatim rather than restating it. + # #484's two `_initials` rules. Both are literal name lists; + # reach 31 and 1, explaining 29 and 1 (2026-09-25), the gap + # being 'nguyen van lac' and 'Nguyen Van Lac' below -- and + # both digests match the 1.4.0 ledger's, which is the point + # of copying the list verbatim rather than restating it. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and + # NOT explained at this baseline -- its report moves here, + # which keeps `_initials` out of its diff (#484). + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- the + # case-insensitive regex matches it too, and its role move is + # explained by the Title-case Lac rule below, so #484 keeps + # `_initials` out of its diff the same way. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462) reaches more than it explains -- the reach is the @@ -4439,6 +4573,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's eight rules: five literal alternations and three + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -4734,6 +4888,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's eight rules: five literal alternations and three + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #436/#437's Latin alternation, first in every ledger. # Ten corpus names, `suffix` alone: the rule moves the # SEPARATOR and no role, so a widening that took a role would @@ -4932,8 +5106,13 @@ def _claim(rule: dict) -> _Claim: # by this rule's own description. # 2026-09-18, #531: 20 -> 21, the same one new name # ('Doe, MA') as the 1.4 twin. + # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, + # meng' and 'Smith, MEng' -- reached by the same post-comma + # shape this rule matches, but read the opposite way and + # explained by neither: at this baseline the diff moves + # `given`, a field outside this rule's own ('suffix', 'title'). "fix(#296) a lone post-comma credential is a suffix": - _Claim(21, ('suffix', 'title'), "3a950b6bb63d", None), + _Claim(23, ('suffix', 'title'), "54c1ae9911e1", None), "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": _Claim(6, ('given', 'suffix', 'title'), "7911e0158337", None), "fix(#325) a credential run across a second comma reads as suffixes": @@ -4990,8 +5169,12 @@ def _claim(rule: dict) -> _Claim: # shipped in 2.2.0, so it is equally visible from either 2.x # baseline, and the reaches and digests agree because the two # files carry the same literal list. + # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and + # not explained here, as in the 2.0.0 mapping. + # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac', reached and + # not explained here, as in the 2.0.0 mapping. "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(29, ('_initials',), "541df583f5fc", ('DEFAULT',)), + _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), "fix(#360) los joined the particles, so it no longer initials": _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), # fix(#462), reach and digest as in the 2.0.0 mapping: the same @@ -5224,6 +5407,26 @@ def _claim(rule: dict) -> _Claim: # zero role movers at review time. "change(suffix-acronym-collisions) ph leaves the acronym set": _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), + # #540's eight rules: five literal alternations and three + # literal names, every name one of #540's own case rows. The + # report rule's `_ambiguities`-only roles are its point: a + # widening that took a role would change them here first. + "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": + _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": + _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), + "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": + _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), + "fix(#540) a lone meng or MEng after a family comma reads as the given name": + _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), + "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": + _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), + "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma": + _Claim(2, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "17e8a418c176", ('DEFAULT',)), + "fix(#540) a Title-case Lac behind a particle is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), + "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name": + _Claim(1, ('_ambiguities', 'family', 'suffix'), "1d95a4b7cfc9", ('DEFAULT',)), # #383/#479's three rules, the first this ledger carries. The # role rule is the 2.x shape of the 1.4.0 rule of the same # name -- two corpus names, the union of two disjoint role diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index cb693ea6..fd5924db 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -1215,10 +1215,22 @@ def test_a_listed_acronym_that_is_a_name_word_gets_no_mask() -> None: in a NAME role the word repairs as a title-cased name word, and in the suffix role, with no mask, the acronym clause writes it in capitals. The recorded negative control is the mask added back, - which re-spells the person.""" + which re-spells the person. + + Since #540 `meng` and `lac` are ambiguous acronyms, so a bare + trailing one after a lone given name is the FAMILY name + (decisions.md#suffix-acronym-collisions): `wang meng` repairs to + `Wang Meng`, where the credential reading gave `Wang MENG`. With + words to spare the credential reading, and its capitals, stay. + That puts the trailing slot on both sides of the fork, and the + control shows the mask re-spelling the surname there too: + `Wang MEng`, `Tran LAc`.""" for text, repaired in (("MENG LI", "Meng Li"), ("edd smith", "Edd Smith"), + ("wang meng", "Wang Meng"), + ("tran lac", "Tran Lac"), ("john smith meng", "John Smith MENG"), + ("nguyen van lac", "Nguyen Van LAC"), ("john smith edd", "John Smith EDD")): assert str(parse(text).capitalized()) == repaired, text hn = HumanName(text) @@ -1228,9 +1240,11 @@ def test_a_listed_acronym_that_is_a_name_word_gets_no_mask() -> None: masked = Parser(lexicon=dataclasses.replace( default, capitalization_exceptions=tuple( default.capitalization_exceptions) - + (("meng", "MEng"), ("edd", "EdD")))) + + (("meng", "MEng"), ("edd", "EdD"), ("lac", "LAc")))) for text, respelled in (("MENG LI", "MEng Li"), - ("edd smith", "EdD Smith")): + ("edd smith", "EdD Smith"), + ("wang meng", "Wang MEng"), + ("tran lac", "Tran LAc")): assert str(masked.capitalized(masked.parse(text))) == respelled diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 7088c901..ed4b3072 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -238,6 +238,7 @@ "Ménil Christophe de" "Ménil de" "Nguyen Thi Van" +"Nguyen Van Lac" "Nguyen, Thi Van" "Nguyen, Van" "Nguyen, Van Le" @@ -308,6 +309,7 @@ "Van Johnson" "Vega, Juan de la" "Vincent van Gogh van Beethoven" +"Wang M.Eng." "Xyz. John Smith" "Xyz. Smith, John" "Xyz. van Johnson" @@ -343,6 +345,7 @@ "john doe q.c." "john e smith" "john smith EdD" +"john smith MEng" "john smith bsc" "john smith ed" "john smith iii" @@ -353,6 +356,7 @@ "john smith mba" "john smith mcse" "john smith md" +"john smith meng" "john smith p.h.d." "john smith ph. d." "john smith ph.d." @@ -380,9 +384,11 @@ "mohamad ali smith" "née Jones" "qc mp" +"tran lac" "van Berg Jan de" "van Gogh" "van der Berg, abdul née Jones" +"wang meng" "Иван Петрович Абрамович" "Сидоров Иван Петрович" "Хосе И Мария Сантос" diff --git a/tools/differential/corpus_shapes.jsonl b/tools/differential/corpus_shapes.jsonl index 5053cae5..c8f65f2e 100644 --- a/tools/differential/corpus_shapes.jsonl +++ b/tools/differential/corpus_shapes.jsonl @@ -58,6 +58,7 @@ {"name": "John Smith E.S.Q.", "shape": 1} {"name": "John Smith J.u.n.i.o.r.", "shape": 1} {"name": "John Smith Jr.", "shape": 1} +{"name": "John Smith MEng PhD", "shape": 1} {"name": "John Smith Ma", "shape": 1} {"name": "John Smith Ph.", "shape": 1} {"name": "John Smith Q.W.E.R.T.", "shape": 1} @@ -95,7 +96,9 @@ {"name": "Md Abdul Karim", "shape": 1} {"name": "Minjun KIM", "shape": 1} {"name": "Mr MA", "shape": 1} +{"name": "Nguyen Van Lac", "shape": 1} {"name": "Sir Bob Andrew Dole", "shape": 1} +{"name": "Wang M.Eng.", "shape": 1} {"name": "X.Y.Z. Smith", "shape": 1} {"name": "abdul Smith Jr Ma", "shape": 1} {"name": "anh van do", "shape": 1} @@ -104,11 +107,17 @@ {"name": "jane doe nee smith ma", "shape": 1} {"name": "john e smith", "shape": 1} {"name": "john smith MA", "shape": 1} +{"name": "john smith MEng", "shape": 1} +{"name": "john smith meng", "shape": 1} {"name": "john smith x.y.z.", "shape": 1} {"name": "jose e maria santos", "shape": 1} {"name": "josep carod i rovira", "shape": 1} {"name": "juan garcia y lopez", "shape": 1} {"name": "juan y garcia", "shape": 1} +{"name": "meng li", "shape": 1} +{"name": "nguyen van lac", "shape": 1} +{"name": "tran lac", "shape": 1} +{"name": "wang meng", "shape": 1} {"name": "ХОСЕ И МАРИЯ САНТОС", "shape": 1} {"name": "Хосе И Мария Сантос", "shape": 1} {"name": "хосе и мария сантос", "shape": 1} @@ -189,10 +198,13 @@ {"name": "Smith, E.S.Q.", "shape": 2} {"name": "Smith, John", "shape": 2} {"name": "Smith, John Jr.", "shape": 2} +{"name": "Smith, John MEng", "shape": 2} {"name": "Smith, John V, Jr.", "shape": 2} {"name": "Smith, John, Extra, Jr.", "shape": 2} {"name": "Smith, MA", "shape": 2} +{"name": "Smith, MEng", "shape": 2} {"name": "Smith, Ma", "shape": 2} +{"name": "Smith, meng", "shape": 2} {"name": "de la Vega, Juan", "shape": 2} {"name": "doe, john ma", "shape": 2} {"name": "Davis Royce, Ed", "shape": 3} @@ -205,8 +217,10 @@ {"name": "John Smith, MD, Ma", "shape": 3} {"name": "John Smith, MD, R.A.I.", "shape": 3} {"name": "John Smith, PhD", "shape": 3} +{"name": "John Smith, PhD MEng", "shape": 3} {"name": "Steven Hardman, MD, DO, DDS", "shape": 3} {"name": "john smith, ma", "shape": 3} +{"name": "john smith, phd meng", "shape": 3} {"name": "John Smith, Dr.", "shape": 4} {"name": "de Mesnil Jean, Dr.", "shape": 4} {"name": "de la Cruz Juan Carlos", "shape": 4} diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml index a84fc800..4f6459b4 100644 --- a/tools/differential/expected_since_1.4.0.toml +++ b/tools/differential/expected_since_1.4.0.toml @@ -3091,17 +3091,22 @@ issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 1.4.0 contributed nothing for such a part ('Anh Do' -> 'A.'; the # tree gives 'A. D.'). The fields did not move, only the view, which # is why no rule could classify this until #484 gave the gate an -# initials column. Reach exactly the 28 names that move (25 spellings, -# three of them case variants), listed literally rather than shaped -# -- 'anh van mc' joined on 2026-09-18 with the case row that -# admitted it to the corpus, the unambiguous-vocabulary twin of the -# 'anh van do' already here: +# initials column. Reach exactly the names that move, listed +# literally rather than shaped (31 corpus names over 26 spellings on +# 2026-09-25; _CORPUS_CLAIMS holds the live reach) -- 'anh van mc' +# joined on 2026-09-18 with the case row that admitted it to the +# corpus, the unambiguous-vocabulary twin of the 'anh van do' already +# here, and 'nguyen van lac' on 2026-09-25 with #540's, its family +# 'van' a one-particle part whose initial 1.4.0 dropped ('n.' -> +# 'n. v.'), which is this rule's reason and not #540's -- the +# case-insensitive regex reaches the later Title-case 'Nguyen Van +# Lac' too, one spelling for two corpus names: # the shape is "a part of nothing but particles", which no regex over # the raw string can state, and the fix(#445) rule above set the # precedent for a name list in THIS ledger (fix(#410) and fix(#335) # are two more). 'de los Santos' is NOT here: its # initials moved for a different reason (the rule below). -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -4390,3 +4395,97 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Four rules here +# where the 2.x ledgers carry eight. 'wang meng' and 'tran lac' read the +# family at 1.4.0 as the tree does, 'Smith, meng' and 'Smith, MEng' read +# the given name the same way, and 'john smith meng' and 'nguyen van lac' +# keep 1.4.0's suffix -- their only new thing is a report, and +# `_ambiguities` is a v2 surface this baseline does not compare. ('nguyen +# van lac' does diff here, on `_initials`, for fix(#385/#402)'s reason, +# and that rule's list carries it; the Title-case 'Nguyen Van Lac' +# reaches that same rule too, case-insensitively, but its role move is +# explained by the Title-case Lac rule below instead.) 'Wang M.Eng.' +# also reads the family here, for an unrelated reason -- its two-piece +# rule (a lone word after the given name is the family), not S2's +# gate -- which is why that rule is 2.x only. What is left is four +# rules: the marking's cost, paid twice +# (once bare behind a full name, once after a family comma), the same +# cost arriving as a credential run behind a suffix comma, and the +# Title-case Lac gain that is the cost's mirror. +# decisions.md#suffix-acronym-collisions records the decision. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', +# where 1.4.0 read suffix 'MEng'. In a name written in more than one +# case rules.md#S2 reads a member "written in any other cased form +# that is not wholly lower" as the name "even with words to spare", +# and MEng is conventionally written in exactly that form. The same +# rule as the 2.x ledgers' of this name, without `_ambiguities`. +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where 1.4.0 +# read suffix 'MEng, PhD'. +# +# Literal, two names; the one-case spellings are _MUST_NOT_MATCH +# probes. +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" +fields = ["family", "middle", "suffix"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# where 1.4.0 read suffix 'MEng'. The same S2 case reading the rule +# above pins, arriving after a family comma, where C1 has already +# settled the word count and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, 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' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). 1.4.0 read the whole run as a suffix. The +# pre-existing 'john smith, phd ma' path, which #540 routes two more +# words into; #544 asks whether the run should keep its comma. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', where 1.4.0 read +# family 'Van', suffix 'Lac'. The mixed-case name's Title-case member +# leans 'name' with words to spare (S2) -- the gain side of the +# marking's cost, for a Vietnamese name (Lac is the trailing word in +# native order, Nguyen Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps its 1.4.0 +# suffix and is a _MUST_NOT_MATCH probe, along with the superstring +# 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml index c14bf7ca..5888e41d 100644 --- a/tools/differential/expected_since_2.0.0.toml +++ b/tools/differential/expected_since_2.0.0.toml @@ -1820,9 +1820,13 @@ orders = ["FAMILY_FIRST_GIVEN_LAST"] issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, # shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same 28-name literal list as the -# 1.4.0 rule, for the same reason. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +# baseline; only the view moved. The same literal list as the 1.4.0 +# rule, for the same reason. 'nguyen van lac' is in it since +# 2026-09-25 and explains nothing here: its report moves at this +# baseline, and the derived view enters a diff only where every +# role and every ambiguity kind agree (#484), so the list stays one +# list without claiming anything new. +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -3281,3 +3285,181 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, two names: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, 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' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; #544 asks whether the run should keep its +# comma. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# 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, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml index a8fd61dd..8f7acdaf 100644 --- a/tools/differential/expected_since_2.1.0.toml +++ b/tools/differential/expected_since_2.1.0.toml @@ -1741,9 +1741,13 @@ orders = ["FAMILY_FIRST_GIVEN_LAST"] issue = "fix(#385/#402) an all-particle name part initials its words (R2)" # 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, # shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same 28-name literal list as the -# 1.4.0 rule, for the same reason. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen, van le|van berg jan de)$" +# baseline; only the view moved. The same literal list as the 1.4.0 +# rule, for the same reason. 'nguyen van lac' is in it since +# 2026-09-25 and explains nothing here: its report moves at this +# baseline, and the derived view enters a diff only where every +# role and every ambiguity kind agree (#484), so the list stays one +# list without claiming anything new. +name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" fields = ["_initials"] orders = ["DEFAULT"] @@ -3192,3 +3196,181 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, two names: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, 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' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; #544 asks whether the run should keep its +# comma. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# 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, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml index f143faa1..54cecc2f 100644 --- a/tools/differential/expected_since_2.2.0.toml +++ b/tools/differential/expected_since_2.2.0.toml @@ -1651,3 +1651,181 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, two names: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, 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' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; #544 asks whether the run should keep its +# comma. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# 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, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml index 7415ed28..1eb3ec9f 100644 --- a/tools/differential/expected_since_2.3.0.toml +++ b/tools/differential/expected_since_2.3.0.toml @@ -968,3 +968,181 @@ issue = "change(suffix-acronym-collisions) ph leaves the acronym set" # unexplained here by design: widen this rule to it, do not file it. name_regex = "^John Smith Ph\\.$" fields = ["family", "middle", "suffix"] + +# --------------------------------------------------------------- +# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Eight rules +# -- the two names whose family name comes back, the marking's +# cost behind a full name, the same cost after a family comma, the +# lone word after a family comma (given name back, cost for the +# conventional spelling), the same cost arriving as a credential +# run behind a suffix comma, the chunked dotted M.Eng. cost, the +# Title-case Lac gain that is the cost's mirror, and the two that +# keep the credential and gain a report. No rule of the parser +# changed: the two words joined suffix_acronyms_ambiguous, and +# rules.md#S2 reads them as it reads 'ma' and 'ba' -- "A BARE +# ambiguous acronym is consumed only when the name has words to +# spare". decisions.md#suffix-acronym-collisions records the +# decision, and the removal and the masks it declined. +# +# Every name here is one of #540's own shape-tagged case rows. No +# corpus name written before the change carried a bare trailing +# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at +# 120033b5), so the gate saw nothing until the rows admitted it. +# One set of eight rules for the four 2.x ledgers; the 1.4.0 +# ledger carries four of them (every rule but the family-comes-back +# one, the lone-word one, the dotted one and the report one), 1.4.0 +# having read the other seven names' roles as the tree does. +# --------------------------------------------------------------- + +[[change]] +issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" +# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and +# 'tran', 'lac') with a suffix-or-name report, where this baseline +# read suffix 'meng' and no family name at all -- reporting nothing +# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the +# one-word-name fork, which was the wrong fork. 1.4.0 read the +# family, which is why that ledger has no copy of this rule. +# +# Literal-anchored to the two names. The class is every name whose +# last word is a bare member with nothing to spare, and a regex for +# it would stand ready to explain a regression on the spellings that +# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py +# carries 'meng li', where the word LEADS and nothing moved. The +# members are names, not a copy of any wordlist, which is what +# _NOT_A_VOCABULARY_COPY records. +name_regex = "^(?:tran lac|wang meng)$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" +# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with +# a suffix-or-name report, where every release read suffix 'MEng'. +# The marking's stated cost. In a name written in more than one case +# rules.md#S2 reads a member "written in any other cased form that is +# not wholly lower" as the name "even with words to spare", and MEng +# and LAc are conventionally written in exactly that form -- the cost +# 'John Smith Ma' already carries. +# +# 'John Smith MEng PhD' pays the same cost and keeps going: the +# declined pick stops the walk (family 'MEng'), but the unambiguous +# credential behind it is still peeled (suffix 'PhD'), where every +# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). +# +# A rule of its own rather than a member of the one above, so the +# gate's summary and the release note name the cost under its own +# heading. Literal, two names: the one-case spellings 'john smith +# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding +# where the writing says nothing, and are _MUST_NOT_MATCH probes. +name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" +fields = ["family", "middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" +# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', +# with a suffix-or-name report, where every release read suffix +# 'MEng'. The same S2 case reading the rule above pins, arriving +# after a family comma, where C1 has already settled the word count +# and the writing is the only evidence left. +# +# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's +# name-word count, and the all-lower 'Smith, John meng' keeps the +# credential too -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, John MEng$" +fields = ["middle", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" +# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family +# 'Smith', with a suffix-or-name report, where this baseline read +# suffix and no given name at all -- the third comma path, S2 +# declining the post-comma credential when the word count leaves it +# a name and C1 reading it as the given name. 1.4.0 already read +# the given name here, so that ledger carries no copy of this rule: +# the all-lower spelling restores 1.4.0's reading, and the +# mixed-case spelling is the cost side of the same path. +# +# Literal, two names; 'Smith, MENG' keeps the credential on the +# capitals lean, and 'Smith, John MEng' belongs to the middle-name +# rule above -- both are _MUST_NOT_MATCH probes. +name_regex = "^Smith, (?:MEng|meng)$" +fields = ["given", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" +# 'john smith meng' and 'nguyen van lac': every role is what this +# baseline read -- suffix 'meng', suffix 'lac' -- because a full name +# in front leaves the credential reading available, and what is new +# is the report rules.md#S2 makes at the trailing slot, where "either +# reading carries the ambiguity flag". `_ambiguities` alone, so this +# rule cannot absorb a ROLE diff on either name, which is the point +# of keeping it apart from the two above. +name_regex = "^(?:john smith meng|nguyen van lac)$" +fields = ["_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a credential run behind a suffix comma that ends in meng re-reads the comma as a family comma" +# 'John Smith, PhD MEng' and 'john smith, phd meng': a credential run +# behind a suffix comma is no longer wholly suffix-shaped once its last +# word is a bare ambiguous member, 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' gives given 'PhD', middle 'MEng', +# family 'John Smith'; 'john smith, phd meng' gives given 'phd', +# family 'john smith', suffix 'meng' (the count then peels 'meng' +# with a word to spare). Every release read the whole run as a suffix. +# The pre-existing 'john smith, phd ma' path, which #540 routes two +# more words into; #544 asks whether the run should keep its +# comma. +# +# Literal, two names, fields the union of what each moves ('middle' is +# the first name's alone -- the OVER-DECLARED check accepts a field +# that at least one explained name moves). Probes: 'John Smith, MEng' +# (a lone credential after the comma keeps the suffix, C1's count) and +# 'John Smith, PhD' (a lone credential of the other shape, also kept) +# are _MUST_NOT_MATCH, along with the superstring 'Dr. John Smith, PhD +# MEng'. +name_regex = "^(?:John Smith, PhD MEng|john smith, phd meng)$" +fields = ["given", "middle", "family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) a Title-case Lac behind a particle is the family name" +# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a +# suffix-or-name report, where every release read family 'Van', suffix +# 'Lac'. The mixed-case name's Title-case member leans 'name' with +# words to spare (S2) -- the gain side of the marking's cost, for a +# Vietnamese name (Lac is the trailing word in native order, Nguyen +# Van Lac). +# +# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix +# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, +# along with the superstring 'Dr. Nguyen Van Lac'. +name_regex = "^Nguyen Van Lac$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"] + +[[change]] +issue = "fix(#540) accepted: a chunked dotted M.Eng. with nothing to spare is the family name" +# 'Wang M.Eng.': given 'Wang', family 'M.Eng.', with a suffix-or-name +# report, where every release from 2.0.0 read suffix 'M.Eng.' with no family name. +# 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, +# so 'M.Eng.' with nothing to spare reads as the ambiguous bare word +# does and the family name comes back. Accepted and recorded rather +# than widening the period gate (Derek, 2026-09-25); 1.4.0 already read +# this family, unflagged, for an unrelated reason (its two-piece rule: +# a lone word after the given name is the family), so that ledger +# carries no copy of this rule. +# +# Literal, one name; 'John Smith M.Eng.' keeps the suffix by the count +# (words to spare) and 'Wang M.A.' keeps the suffix too (M.A. passes +# the period gate, unaffected by this change) -- both are +# _MUST_NOT_MATCH probes, along with the superstring 'Dr. Wang M.Eng.'. +name_regex = "^Wang M\\.Eng\\.$" +fields = ["family", "suffix", "_ambiguities"] +orders = ["DEFAULT"]