diff --git a/AGENTS.md b/AGENTS.md index 3098c3a39..96e6a0ca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely o ### Vocabulary (`nameparser/vocab/`) and the configuration layer (`nameparser/config/`) -**The default vocabulary is written per word, in TOML, and compiled.** `nameparser/vocab/.toml` holds one file per BCP 47 language tag, each `[[word]]` entry a `text` and its readings keyed by kind. What a file means, how an entry is built, the kinds and properties with the set each compiles into, exclusions, bearer counts and the steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing the TOML; `tests/v2/test_vocab.py` holds its kind, property, derived-marker and reserved-name tables to the names `build.py` accepts and the sets it compiles, so read it rather than restating it here. The parser never reads the TOML: `tools/vocab/build.py` runs the build checks that replaced the modules' import-time asserts (its docstring numbers them), turns every reading into an index row with its file's tag, and writes the rows as the committed reading index `nameparser/config/_index.py` (#753, phase 3a) and, compiled, as the committed `nameparser/config/_compiled.py`, frozenset literals one entry per line; the build never reads `_index.py` back. A file a shipped pack reads may also hold a `[rules]` table, each key a `Policy` field (`patronymic_rules`, `name_order`, `two_family_names`), which the build checks against its `RULE_KEYS` and `PACK_RULES` and writes as `nameparser/config/_rules.py`; `_locale.rules_patch(tag)` turns a file's table into the `PolicyPatch` RU and TR_AZ carry, from `ru.toml` and `tr.toml` (#754, 4f; decisions/vocabulary-model's 2026-10-29 4f entry). The compiler is `nameparser/_reading_index.py`'s `compile_readings`, with the kind and property tables it reads (`CATEGORY`, `BEHAVIORAL`, `GATED_WORDS`), so the build and a choice of languages compile alike: `Locale.languages` (3.0, phase 3b) is a choice of BCP 47 tags, which `parser_for` unions across its packs and applies as a delta against the default's compile, `choose` there selecting the readings (a chosen file's `default = false` ones on; on a word a chosen file holds a vocabulary reading of, other files' vocabulary readings off, save `mul.toml`'s and `und.toml`'s; a chosen conjunction's `may_be_initial` dropped), and `changed_words` deriving a choice's deviation surface, which tests/v2/test_locales.py's gate reads (decisions/vocabulary-model, 2026-10-29 3b); the build keeps the checks and loads that module by path, as it loads `_lexicon.py`, and `tests/v2/test_reading_index.py` holds that the index compiled with no choice is `_compiled.py` exactly. `readings_of(text)` there is the run-time reader of the index, every reading of a word with its file's tag, loaded on first call; `languages_of(text, categories)` is the reader a parse calls, from assemble, for `Reading.languages` (3.0), so the first parse that reads a word from its `Lexicon`'s vocabulary loads the index. Each `config/` module re-exports its constants from `_compiled.py` (`TITLES = _compiled.TITLES`) under its autodoc docstring, so the public names and the docs did not move. `tests/v2/test_vocab.py` fails when `_compiled.py` is stale, and `tests/v2/test_reading_index.py` when the index is, each naming the refresh command, and `test_vocab.py` gives every build check a mutation. The model, the phasing and what was declined: decisions/vocabulary-model. +**The default vocabulary is written per word, in TOML, and compiled.** `nameparser/vocab/.toml` holds one file per BCP 47 language tag, each `[[word]]` entry a `text` and its readings keyed by kind. What a file means, how an entry is built, the kinds and properties with the set each compiles into, exclusions, bearer counts and the steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing the TOML; `tests/v2/test_vocab.py` holds its kind, property, derived-marker and reserved-name tables to the names `build.py` accepts and the sets it compiles, so read it rather than restating it here. The parser never reads the TOML: `tools/vocab/build.py` runs the build checks that replaced the modules' import-time asserts (its docstring numbers them), turns every reading into an index row with its file's tag, and writes the rows as the committed reading index `nameparser/config/_index.py` (#753, phase 3a) and, compiled, as the committed `nameparser/config/_compiled.py`, frozenset literals one entry per line; the build never reads `_index.py` back. A file a shipped pack reads may also hold a `[rules]` table, each key a `Policy` field (`patronymic_rules`, `name_order`, `two_family_names`, `two_given_names`), which the build checks against its `RULE_KEYS` and `PACK_RULES` and writes as `nameparser/config/_rules.py`; `_locale.rules_patch(tag)` turns a file's table into the `PolicyPatch` RU and TR_AZ carry, from `ru.toml` and `tr.toml` (#754, 4f; decisions/vocabulary-model's 2026-10-29 4f entry). The compiler is `nameparser/_reading_index.py`'s `compile_readings`, with the kind and property tables it reads (`CATEGORY`, `BEHAVIORAL`, `GATED_WORDS`), so the build and a choice of languages compile alike: `Locale.languages` (3.0, phase 3b) is a choice of BCP 47 tags, which `parser_for` unions across its packs and applies as a delta against the default's compile, `choose` there selecting the readings (a chosen file's `default = false` ones on; on a word a chosen file holds a vocabulary reading of, other files' vocabulary readings off, save `mul.toml`'s and `und.toml`'s; a chosen conjunction's `may_be_initial` dropped), and `changed_words` deriving a choice's deviation surface, which tests/v2/test_locales.py's gate reads (decisions/vocabulary-model, 2026-10-29 3b); the build keeps the checks and loads that module by path, as it loads `_lexicon.py`, and `tests/v2/test_reading_index.py` holds that the index compiled with no choice is `_compiled.py` exactly. `readings_of(text)` there is the run-time reader of the index, every reading of a word with its file's tag, loaded on first call; `languages_of(text, categories)` is the reader a parse calls, from assemble, for `Reading.languages` (3.0), so the first parse that reads a word from its `Lexicon`'s vocabulary loads the index. Each `config/` module re-exports its constants from `_compiled.py` (`TITLES = _compiled.TITLES`) under its autodoc docstring, so the public names and the docs did not move. `tests/v2/test_vocab.py` fails when `_compiled.py` is stale, and `tests/v2/test_reading_index.py` when the index is, each naming the refresh command, and `test_vocab.py` gives every build check a mutation. The model, the phasing and what was declined: decisions/vocabulary-model. The `config/` SETS are frozen since 2.2 (#293): there is no `.add()`/`.remove()` on any of them, so a default word list is changed at runtime by configuring a `Lexicon`, never by editing the constant, and the shipped default is changed by editing the TOML and rebuilding. **Neither dict was frozen, and `CAPITALIZATION_EXCEPTIONS` is not covered by anything else either.** `REGEXES` is a compiled-pattern table rather than vocabulary and was never in #293's scope; `CAPITALIZATION_EXCEPTIONS` is vocabulary-shaped and is a decided, in-scope exemption. So the hazard the freeze closes is still live for it: `CAPITALIZATION_EXCEPTIONS['dclinpsy'] = 'DClinPsy'` after the first parse misses the cached `Lexicon.default()`. Same advice — configure the object (`dataclasses.replace(lexicon, capitalization_exceptions=...)`). `tests/v2/test_contracts.py::test_every_vocabulary_constant_is_frozen` names both dicts as explicit exemptions rather than letting its `isinstance` filter drop them. diff --git a/docs/concepts.rst b/docs/concepts.rst index 5c80475ba..db6812c06 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -75,7 +75,9 @@ word is the given name, the last is the family name, and anything between them is the middle name. ``name_order``, an explicit comma and ``script_orders`` change what "first" and "last" mean here, as does the opt-in ``two_family_names``, which takes the last two name -words as the family (:ref:`two-family-names`). +words as the family (:ref:`two-family-names`), and the opt-in +``two_given_names``, which gives the given name the middle name beside +it (:ref:`two-given-names`). ``script_orders`` covers the East Asian writing that settles its own order: a name wholly in Han or hangul, or a Japanese name in kanji and kana — any of them, hiragana alone included — unless it is katakana diff --git a/docs/customize.rst b/docs/customize.rst index 405def7a4..c8a0912cd 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -694,6 +694,12 @@ listed below. - Reads the last two name words as the family name, for data where people carry two family names (Spain, most of Latin America). Defaults to ``False``. See :ref:`two-family-names`. + * - ``two_given_names`` + - ``bool`` + - Reads the middle name beside the given name as part of it, for + data where people carry a compound given name and no middle + name (``María José``). Defaults to ``False``. See + :ref:`two-given-names`. * - ``nickname_delimiters`` - ``frozenset[tuple[str, str]]`` - Routes content enclosed by these delimiter pairs to @@ -1017,6 +1023,59 @@ breaks 8,234 names of 20,000 and mends 265. The parser reports nothing for the names the switch moves: the shape it reads is the same either way. +.. _two-given-names: + +Names with a compound given name +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A Spanish or Portuguese given name is often two names, such as María +José or Juan Carlos, and its bearer has no middle name. The default +reads the second as a middle name, the English convention: + +.. doctest:: + + >>> carlos = parse("Juan Carlos Pérez") + >>> carlos.given, carlos.middle + ('Juan', 'Carlos') + +``two_given_names=True`` gives the given name the middle name beside +it. Any middle names past it stay middle names, a family comma leaves +the part after it to the switch, and with ``two_family_names`` on too +the family takes its word first, so a name of four words reads two and +two: + +.. doctest:: + + >>> two_given = Parser(policy=Policy(two_given_names=True)) + >>> two_given.parse("Juan Carlos Pérez").given + 'Juan Carlos' + >>> two_given.parse("Pérez, Juan Carlos Antonio").middle + 'Antonio' + >>> both = Parser(policy=Policy(two_family_names=True, + ... two_given_names=True)) + >>> mj = both.parse("María José García López") + >>> mj.given, mj.middle, mj.family + ('María José', '', 'García López') + >>> both.parse("Gabriel García Márquez").given + 'Gabriel' + +A given name a particle completes reads whole where a second particle +starts the family (``"María del Carmen de la Vega"`` gives given +``María del Carmen``); where the particle's words run to the end of +the name, as in ``"Maria do Carmo Silva"``, they stay the family with +the switch on or off, the same shape as ``"João da Silva Santos"``. + +Turn it on only for data written this way: under it every name whose +second given word is a middle name gains it in the given name +(``"John Quincy Adams"`` gives given ``John Quincy``), and people +filling a form that has a middle-name box split their names the other +way. Among the UK company register's 6,089 Spanish filers, 31.8% of +names agree in every box by default and 15.7% with this switch on; +with ``two_family_names`` on, 80.2% without this switch and 55.0% +with it (measured 2026-10-30). ``middle`` keeps its meaning either way, +the given part's words after the given name; the switch moves no word +anywhere else and reports nothing. + .. _east-asian-defaults: East Asian defaults, and turning them off diff --git a/docs/design/decisions/O7-two-given-names.md b/docs/design/decisions/O7-two-given-names.md new file mode 100644 index 000000000..715afec75 --- /dev/null +++ b/docs/design/decisions/O7-two-given-names.md @@ -0,0 +1,39 @@ +# O7 — two given names + +## Standing + +- A compound given name is an opt-in `Policy` switch, `two_given_names`, off by default, which a pack's `[rules]` may turn on (`two_given_names = true`): given-first it is the same string shape as a name with a middle name, and `middle` keeps its meaning — 2026-10-30 #704. +- The given name takes ONE unit, the middle unit beside it (after it, or before it under FAMILY_FIRST_GIVEN_LAST with no family comma, the part after one reading given-first), counted as rules/O6 counts a name word, so a particle chain the parse left in the middle moves whole; a given name already holding two words keeps what it has — 2026-10-30 #704. +- The switch reads after `two_family_names` and before `middle_as_family`, and is refused beside neither — 2026-10-30 #704. +- A particle chain running to the end of the name stays the family under the switch (`Maria do Carmo Silva`); reading it as a given name is not this rule's — 2026-10-30 #704. +- The switch reports nothing — 2026-10-30 #704. + +## Log + +- 2026-10-30 #704 — THE COMPOUND GIVEN NAME IS O6'S MIRROR: A SWITCH THAT GIVES THE GIVEN NAME THE MIDDLE UNIT BESIDE IT. Carries out sub-phase 4g of the 2026-10-28 #754 output-model entry in decisions/vocabulary-model (its item 7: `middle` keeps its meaning, a compound given name is a rule a caller or pack turns on). Field name `two_given_names`, after `two_family_names`, whose shape it mirrors (mechanisms.md#DECLARED-SPAN). + What the rule settles, and why each way: + - **One unit, not the whole given part.** The given name takes the next unit and later middles stay middles (`Ana María José Pérez` → middle `José`), as O6 takes one word: a given name of two names is the convention named in #704 (`María José`, `Juan Carlos`, CLDR's `Cornelia Margarita` beside `given2` `Sofia Aletta`), and one unit keeps the field CLDR calls `given2`. Weighed against: every given-part word into `given`, which empties `middle` for every name under the switch and reads CLDR's `given2` into `given`. + - **The count.** A unit is counted as rules/O6 counts one, so `María del Carmen de la Vega` gives given `María del Carmen`: P2 stopped the first chain where the second particle opened its own. A given name counting two words already (a hyphen, a P5 pair) keeps what it has; what is taken must count as one and hold a letter, and the given name must hold a letter too (the PSC run found forename boxes holding a house number or a lone mark, `11 Elizabeth Mcguinness`, `. Ajay Singh`). An initial is taken, being a given name written short, the reverse of O6, which refuses one because it writes a given name. + - **A patronymic is no given name, on either side of the join.** A patronymic particle's run is neither taken nor completed, read off the `vocab:patronymic` tag classify left (rules/R9's vocabulary arm): `Ahmad bin Ali bin Hassan` keeps middle `bin Ali`, which the first draft moved into the given name while the patronymic view still claimed it (found by the #954 review), and `Ahmad, bin Ali`, `bin Ali Hassan` and, under FAMILY_FIRST_GIVEN_LAST, `Hassan Yusof bin Ali` keep their given name, which the second draft, testing only the unit taken, joined to the word beside it (found by the third review); so do, under an ending rule, a word the rule reads as a patronymic, a Turkic marker and the marker's father (`Ivan Ivanovich Ivanov` under the East Slavic rule keeps middle `Ivanovich`); with no rule on, a patronymic written without a particle is a name word and joins, as O6 accepts. The #954 review also found the first draft's two edge tests on the unit's position unreachable, over the committed corpora's 6,428 texts under every order, patronymic rule and O6 and O3 setting: the positions lay the middles out on the side of the given name the order says, so the unit beside it is the first middle unit or the last, and the tests were removed. + - **Orders and commas.** A family comma lays the part after it out given-first, so the switch reads it (`Pérez, Juan Carlos`), an empty head (C15) included; no comma, the switch stands down where the name's script set its order (W4), as O6 does. Under FAMILY_FIRST_GIVEN_LAST the unit beside the given name is the one before it, so the two stay in written order. + - **Precedence.** O6 reads first: turning both on says the family holds two names, and O6 takes its word before this rule takes from what is left. Measured in ORCID's records from Spain, Mexico, Colombia, Chile, Peru and Ecuador whose two boxes hold three words with no particle or hyphen, the family box holds two of them in 73% (Ecuador) to 93% (Peru) of the records (Spain 6,586 of 7,433), and where they hold four, two and two in 96% to 99.9% (Spain 2,484 of 2,582; 2026-10-30, the fetch of 2026-10-27), so O6-first reads `Gabriel García Márquez` and `María José García López` as those writers split them. Argentina (2+1 in 7,184 of 8,457 three-word records), the United States and the United Kingdom go the other way, which is why neither switch is a default. `middle_as_family` (O3) reads after, as P5's join reads before it: the given name is decided first, and the fold takes the middles left. + - **The particle chain ending the name is out of scope.** The RFC's measured gap for `pt` is `Maria do Carmo Silva` (given `Maria`, family `do Carmo Silva`), where P2 chains every word after the particle into the family. Splitting that chain needs to know which particle phrases complete a given name, and the shape is `João da Silva Santos`'s, whose family it is. Measured in ORCID's records from Spain, Portugal, Brazil and 19 Latin American countries with a family box filled (2026-10-30): 5,506 given boxes hold a particle inside, against 5,625 family boxes that open with one; the given boxes' objects are mostly Marian titles (`Carmen` 1,283, `Jesús` 454 and `Jesus` 245, `los Ángeles` 564 in two spellings, `Pilar` 324, `Rosario` 260, `la Caridad` 230), several of which open family boxes too (`Jesus` 65, `Rosario` 35, `Valle` 47, `Conceição` 24, against 19 in given boxes). So the split is a question of vocabulary, which word completes a given name after which particle, and is left Open below rather than guessed at by shape. + - **No report.** The caller declared the convention, as for O6. + Measured 2026-10-30 on the branch, the default unchanged (the snapshot gains the rule's and case table's example texts and moves no reading; every agreement record matches): + - **PSC** (`psc-individuals`, whose form has a middle-name box; every box agreeing as `local_agreement.py` scores it, records scored by nationality, a set-aside title box excluded): + + | Filers | Records | default | O7 | O6 | O6+O7 | + |---|---:|---:|---:|---:|---:| + | Spanish | 6,089 | 31.8% | 15.7% | 80.2% | 55.0% | + | Portuguese | 5,333 | 61.2% | 28.5% | 56.2% | 35.9% | + | Brazilian | 1,920 | 53.6% | 20.9% | 55.8% | 43.1% | + | Mexican | 402 | 25.9% | 10.2% | 80.1% | 37.6% | + | Colombian | 377 | 29.2% | 12.7% | 84.4% | 28.6% | + | Argentine | 320 | 81.9% | 28.4% | 45.6% | 34.7% | + + The filers split a compound given name across the form's two boxes, as #754's 2026-10-28 count found (of 6,101 Spanish nationals, 25 write two words in the forename box and 2,621 fill the middle box), so the register measures what its form asks for, not the convention. `local_agreement.py psc-individuals --base` agrees: against the default, 550,064 records regress and 1,801 progress; with O6 on in both trees, 22,297 and 173 (the final commit). + - **ORCID** (`local_agreement.py orcid-researchers --base`, O7 against the default and O6+O7 against O6): matches the base both times. Its given box holds the middle names too and is scored against given and middle joined, so it cannot see this rule, as the RFC's output-model section says of a two-word given box there. + - **CLDR** (`cldr_fields.jsonl`'s given-first renderings with a stated given name, given agreeing): over all 880, default 636, O7 568, O6 636, O6+O7 746; over `es`'s 8, 6 → 8 under O6+O7; over `pt`'s 8, 7 under O6+O7 and 5 under O7 alone. + - **TSE** (`snapshot.py --local tse-donors --base`, Brazilian donors' names as filed): 689,057 of 854,531 entries read differently under O7, 684,625 of them in given and middle alone and 310 in an ambiguity's alternative too, and with O6 on in both trees 270,169 (268,478 in given and middle alone), measured on the final commit; the corpus states no fields, so it counts reach, not agreement. + Recompute: the ORCID counts read `raw.jsonl` of the 2026-10-27 fetch, records with a family box, the three- and four-word split over records whose boxes hold no hyphen and no word of `Lexicon.default().particles` (conjunctions not excluded), by the record's `country`; the particle-inside count over BR, PT, ES, MX, AR, CO, CL, PE, VE, EC, UY, BO, PY, CR, GT, CU, DO, HN, SV, NI, PA and PR, a given box whose word at neither end is in `particles`, its object the next word (the next two after a second particle), against family boxes of two or more words opening with one. The PSC table is `local_agreement.score(rows, FIELDED[PSC], Parser(policy=...), jobs=1)` in one process, `rows` the records whose `group_of` is the filers' nationality, each table cell a group's `agreed` over its `scored`: every box compared case-folded, the title too, a parse's suffix appended to the family before the surname box is compared, and a record whose title box holds an unlisted word that disagrees set aside and not scored. An earlier draft of this table compared the raw boxes without that read and gave a few tenths of a point less. The CLDR tables score each rendering's `given` against `parse()` under each `Policy` in one process; the local corpora's runs are `--tree` a checkout whose `two_given_names` (and for O6+O7 `two_family_names`) default is flipped, `--base` one at master `40675146` flipped the same way for O6. + - **Open:** which particle phrases complete a given name, and whether that is a reading on the word or a list the rule kind holds (the particle-chain bullet above), #704; whether `es` and `pt` turn the switch on, which 3g decides against these measurements, #754; deriving `DEVIATES` from the rules (decisions/vocabulary-model's 2026-10-29 4f entry, item 6), which this rule leaves as it found it, having no surface in the name to declare, #754. diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index b4d62d5c4..57ffb6084 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -97,6 +97,7 @@ Entry conventions: - [O4 — positional assignment and declared order](O4-positional-assignment-and-declared-order.md) - [O5 — the lone name word's convention, and reporting it](O5-lone-name-words-convention-and-reporting-it.md) - [O6 — two family names](O6-two-family-names.md) +- [O7 — two given names](O7-two-given-names.md) ## Scripts & writing systems (W) diff --git a/docs/design/decisions/vocabulary-model.md b/docs/design/decisions/vocabulary-model.md index 09c1233e5..0a8e47532 100644 --- a/docs/design/decisions/vocabulary-model.md +++ b/docs/design/decisions/vocabulary-model.md @@ -342,3 +342,4 @@ Open: #913 (the title words phase 2j left, read as titles though borne as names) - **Not a rule change.** rules/H9's When already names "a title word that is also borne as a given name", and the choice adds three words to that class without moving its limits: `His Holiness Guru Prasad` keeps title `His Holiness Guru` (a style partner), `Swami Guru Prasad` reads title `Swami`, given `Guru` (H9's Accepted, a religious title behind any title but a style), and `Guru. Prasad Sharma` keeps title `Guru.` by rules/H2's shape, while `श्री. कृष्ण श्रेष्ठ` reads given `श्री.`, H2's shape never reading a Devanagari word (its Accepted clause on abugidas). Pinned in tests/v2/test_locales.py. - **Open: a period-joined token whose only title chunk the choice makes possibly given reads as a name word, in both directions.** `_vocab.period_joined_vocab` makes a token a title where ANY chunk is in `titles`, and under `ne` `श्री` has moved to `titles_ambiguous`. That costs `सु.श्री.` (सुश्री, Ms., written with its periods), title under the default and given under `ne`, 2 nepal-candidates names; and it fixes a given name glued behind the word, `श्री.राम थापा`, title `श्री.राम` under the default and given `श्री.राम` under `ne`. Whether a possibly-given chunk counts there is open; a fix would move the default for every word of `TITLES_AMBIGUOUS`, and its grid holds both `सु.श्री.` and `श्री.X`. - **Recompute:** the three commands above from a checkout of this change; the CLDR record with `cldr_agreement.py`; the word counts with `tools/name_usage.py guru गुरु श्री`. +- **2026-10-30 #754, sub-phase 4g — THE COMPOUND GIVEN NAME IS A RULE KIND: `Policy.two_given_names`, rules/O7, OFF BY DEFAULT AND A `[rules]` KEY; NO DEFAULT PARSE MOVES.** Carries out item 7 of the 2026-10-28 #754 output-model entry and the 4g row of its item 9. The rule kind is built as the 4f entry's item 1 says a new one is: a `Policy` field with `ADDED_IN` (`3.0.0`, so a 2.8 `Policy` pickle loads with it off, tests/v2/data/policy_v28.pickle), a `flag` row in `tools/vocab/build.py`'s `RULE_KEYS` with its default `false` refused, a row in `_locale._RULE_VALUES`, and a mechanisms.md entry, DECLARED-SPAN, which it shares with rules/O6's switch, whose mirror it is. No file turns it on: whether `es` and `pt` do is 3g's. What the rule reads, its precedence against O6 and O3, and the measurements are decisions/O7's 2026-10-30 entry; the `Maria do Carmo Silva` shape the RFC's table names stays unread, the particle chain running to the end of the name, and is Open there. Item 6 of the 4f entry, deriving `DEVIATES` from the rules, stays open: this rule has no surface in the name for a pack to declare. diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index b92592e2d..6a7ac2b77 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -316,6 +316,18 @@ a fork for all ambiguous particles on "Dr. Van Jr.". **Reach for it when.** Tempted to write "remember to keep X in sync with Y." +## DECLARED-SPAN — a caller switch moves one unit across a part's edge + +**Problem shape.** A convention says a part of the name holds two units, a family of two surnames or a given name of two, but written in its order the name is the same string shape as a name whose extra word belongs to the part beside it, so no reading of the words can tell them apart. + +**Contract statement.** A part that a convention spans over two units is a caller switch, a `Policy` field off by default that a pack's `[rules]` may turn on, read after the positional assignment: the part takes the ONE unit standing beside it in the part next to it, counted as UNIT-PARTITION counts one, and declines where the part already holds two words, where the unit is not one name word that part could hold, or, outside a family comma, where the order the name was read in is not the one the caller declared; after a family comma the family's switch declines, the comma having decided the family, and the given name's reads the part after it given-first. + +**How it works.** Each switch is a post rule that retags in place and never reorders: it reads the roles assign settled, takes the neighbouring part's units as `_units` rebuilds them from the tags, and moves the unit at the shared edge, which in text order already renders where it stands, so no view needs a tag to put it back (a nickname written inside the given part is the one exception, rendered after the joined name: `John "Jack" Fitzgerald Kennedy` under O7 renders `John Fitzgerald "Jack" Kennedy`, every word kept, accepted); only the family's switch under a family-first order, where the unit it takes is the given name's and leaves a remainder to lay out again, retags the rest by `_name_positions`. The two switches read in a fixed order, the family's first (rules/O6) and then the given name's (rules/O7), and compound-family handling (rules/O3) folds what both leave, so turning on more than one reads as each states its precedence and none needs a guard against another. A switch reports nothing, the caller having declared the convention, and a pack turns it on as data: one row in `tools/vocab/build.py`'s `RULE_KEYS` and in `_locale._RULE_VALUES`, its default refused there, so a pack can only turn it on (decisions/vocabulary-model, 2026-10-29 4f; decisions/O7, #704). + +**Lives in.** nameparser/_pipeline/_post_rules.py (`_take_second_family_name`, `_take_second_given_name`, and `_family_word_count`, the one count both ask), nameparser/_policy.py (`two_family_names`, `two_given_names`). + +**Reach for it when.** A convention moves a part's boundary by one unit and the string cannot show where it falls: make it a switch of this shape rather than a heuristic over the words, and state its precedence against the switches already here. + ## LOCALE-PACKS-PURE-DATA — packs configure, they never compute **Problem shape.** Language-specific behavior needs a home that cannot drift from the core. diff --git a/docs/design/rules/O7-two-given-names.md b/docs/design/rules/O7-two-given-names.md new file mode 100644 index 000000000..7a1f9188e --- /dev/null +++ b/docs/design/rules/O7-two-given-names.md @@ -0,0 +1,110 @@ +# O7 — two given names + +**When:** two-given-name handling is active and the given name stands beside a middle name, as in "Juan Carlos Pérez". + +**Then:** the given name takes the middle unit beside it, so the given name is two name units and any middles past them stay middles. + +- **Under the default given-first order, and after a family comma**, the middle written right after the given name joins it. + ``` + "Juan Carlos Pérez" two_given_names → given="Juan Carlos" + "Ana María José Pérez" two_given_names → middle="José" + "Pérez, Juan Carlos" two_given_names → given="Juan Carlos" + ``` +- **Under a family-first order with no family comma** the middle after the given name joins it, and under the family-first order with the given name last the middle before it, so the two stay in the order they were written; after a family comma the part is read given-first under every order, as the bullet above says. + ``` + "García Juan Carlos" family-first+two_given_names → given="Juan Carlos" + "García Antonio Juan Carlos" family-first-given-last+two_given_names → given="Juan Carlos" + "García Antonio Juan Carlos" family-first-given-last+two_given_names → middle="Antonio" + "Pérez, Antonio Juan Carlos" family-first-given-last+two_given_names → given="Antonio Juan" + ``` +- **A unit is counted as O6 counts a name word**, so a particle chain the name left in the middle joins with its words: a given name that a particle and its word complete reads whole. + ``` + "María del Carmen de la Vega" two_given_names → given="María del Carmen" + "Maria do Carmo da Silva" two_given_names → family="da Silva" + ``` +- **An initial is taken**, since it writes a given name short. + ``` + "J. Carlos Pérez" two_given_names → given="J. Carlos" + ``` +- **Titles, suffixes, nicknames and a maiden name are no name units**, and a comma that sets off only suffixes leaves the name before it to this rule. + ``` + "Dr. Juan Carlos Pérez Jr." two_given_names → given="Juan Carlos" + "Juan Carlos Pérez, PhD" two_given_names → given="Juan Carlos" + "Juan Carlos Pérez née López" two_given_names → given="Juan Carlos" + ``` + +**Because:** across Spain, Portugal and Latin America a person may carry a compound given name, such as María José or Juan Carlos, two names that together are the name the person goes by, where the English convention reads the second as a middle name. Written given-first, that name is the same string shape as a name with a middle name, so no rule can read it from the words; the caller who knows the data is written that way says so. + +``` +"Juan Carlos Pérez" → given="Juan" · boundary +"Juan Pérez" two_given_names → given="Juan" · boundary +"Juan Carlos" two_given_names → family="Carlos" · boundary +``` + +**Except:** + +- **A given name that already holds two words keeps what it has**: a hyphen joins two words, and so does a bound given-name pair (P5), so neither takes a third. + ``` + "María-José Ana López" two_given_names → given="María-José" + "abdul Rahman Ali Khan" two_given_names → middle="Ali" + ``` +- **What is taken must be one name word**: a part counting as two words stays where it is, and so does a mark or number with no letter in it; nor does a given name with no letter in it take anything, being no name to complete. + ``` + "Ana María-José López" two_given_names → middle="María-José" + "Juan 3 Pérez" two_given_names → middle="3" + "11 Elizabeth Mcguinness" two_given_names → middle="Elizabeth" + ``` +- **A patronymic is no given name**, on either side of the join: a patronymic particle and the word it names (R9) are neither taken nor completed, and a word an ending rule reads as a patronymic, a Turkic marker and the father's name in front of one stay in the middle. + ``` + "Ahmad bin Ali bin Hassan" two_given_names → middle="bin Ali" + "Ahmad, bin Ali" two_given_names → given="bin" + "Ivan Ivanovich Ivanov" east-slavic+two_given_names → given="Ivan" + "Ivan Ivanovich Ivanov" east-slavic+two_given_names → middle="Ivanovich" + ``` +- **An order a name's script set is not a declared one** (W4), so a name its script reads in an order other than the declared `name_order` is left alone. + ``` + "王 小 明" two_given_names → given="小" + ``` + +**Precedence:** + +- **Against two-family-name handling** ("Gabriel García Márquez" with both switches on): that rule reads first, since the caller who turns both on says the family holds two names, and this rule takes from the middles it leaves. A name of three words then keeps one given name, and a name of four reads two and two. + ``` + "Gabriel García Márquez" two_family_names+two_given_names → given="Gabriel" + "María José García López" two_family_names+two_given_names → given="María José" + "María José García López" two_family_names+two_given_names → family="García López" + ``` +- **Against compound-family handling** ("Hassan Mohamad Ali" with both switches on): this rule reads first, as a bound given-name pair (P5) joins before the fold, and O3 then folds whatever middles are left. + ``` + "Hassan Mohamad Ali Omar" two_given_names+middle_as_family → given="Hassan Mohamad" + "Hassan Mohamad Ali Omar" two_given_names+middle_as_family → family="Ali Omar" + ``` +- **Against a patronymic rotation** ("Ivanov Ivan Ivanovich" with the East Slavic rotation on): the rotation decides the order first, and the patronymic it leaves in the middle is no given name (above). + +**Accepted:** + +- A name whose second given-name word is a middle name gains it in the given name, which is the shape the switch cannot tell from the one it is for: most English names of three words or more read that way under it. That is why it is off by default and a caller turns it on only for data written this way. + ``` + "John Quincy Adams" two_given_names → given="John Quincy" + ``` +- A particle chain the name left in the middle is taken whatever its language, so a Dutch name holding two particle surnames gives its first to the given name. + ``` + "Vincent van Gogh van Beethoven" two_given_names → given="Vincent van Gogh" + ``` +- A particle chain running to the end of the name is the family (P2), so a given name a particle completes is read only where a second particle starts the family: in "Maria do Carmo Silva" the family keeps "do Carmo Silva", the same string shape as "João da Silva Santos", whose family it is. + ``` + "Maria do Carmo Silva" two_given_names → family="do Carmo Silva" + ``` +- Where the declared order and a name's script agree, nothing tells the two apart, so under a declared family-first order a spaced Han or Hangul name is read like any other, as O6 reads it. + ``` + "王 小 明" family-first+two_given_names → given="小 明" + ``` +- Where no ending rule is on, a patronymic written without a particle is a name word like any other and joins the given name. + ``` + "Ivan Ivanovich Ivanov" two_given_names → given="Ivan Ivanovich" + ``` +- The rule reports nothing: the caller declared the convention, as O6's switch reports nothing. + +--- +**Signal:** none in the name: a caller switch, the given name's words and the taken unit's shape read as written +**history:** decisions/O7 · **implemented:** nameparser/_pipeline/_post_rules.py diff --git a/docs/design/rules/README.md b/docs/design/rules/README.md index 92948e0cf..bd7fb3b8a 100644 --- a/docs/design/rules/README.md +++ b/docs/design/rules/README.md @@ -192,6 +192,7 @@ Terms the rules use without defining them each time, each with the rule that def - [O4 — positional assignment and declared order](O4-positional-assignment-and-declared-order.md) - [O5 — the lone name word's convention, and reporting it](O5-lone-name-words-convention-and-reporting-it.md) - [O6 — two family names](O6-two-family-names.md) +- [O7 — two given names](O7-two-given-names.md) ## [Scripts & writing systems (W)](W-scripts-and-writing-systems.md) diff --git a/docs/design/vocabulary-model-rfc.md b/docs/design/vocabulary-model-rfc.md index 473786792..1848472c4 100644 --- a/docs/design/vocabulary-model-rfc.md +++ b/docs/design/vocabulary-model-rfc.md @@ -319,7 +319,7 @@ What decides a view is the reading each token took, so `Token.reading` comes fir - **Generation and credentials** (#326): `generation`, the suffix tokens whose reading is a generation or an agnomen (`Jr.`, `III`, `Filho`), and `credentials`, the rest of the suffix (`PhD`, `Esq.`, the honorifics after a name), so the two partition `suffix` and `suffix` is unchanged. CLDR has only these two buckets; the post-nominals that are neither (`Esq.`, the CJK and Hebrew honorifics, `Rinpoche`) fall in `credentials`, CLDR's catch-all, and a caller who wants them apart reads `Token.reading.kind`, which says `post-nominal`. Measured 2026-10-28 on `c8357e74`, over the committed corpus's 6,533 distinct entries (`snapshot.corpus_entries()`) each parsed under its declared order: of the 1,054 with a suffix, 33 hold a generation (or agnomen) and a listed credential together, 77 a generation beside any other suffix word, and 64 only listed post-nominals of other kinds. Of the 615 CLDR renderings with a generation or credentials field, 75 parse with exactly CLDR's suffix words, and in all 75 a split by the vocabulary's reading kind gives CLDR's `generation` and `credentials` both; the other 540 differ in which words the suffix holds, a parse question the split cannot fix. Recompute: classify each suffix word (a token tagged `joined` merged into the one before it) by the kinds of its suffix readings in nameparser/vocab/, matched lowercased with its periods and spaces removed, skipping `default = false` readings, a generation or agnomen reading winning over a credential and a credential over the other kinds, an unlisted roman numeral `i` to `x` a generation and any other unlisted word unlisted; over the corpus entries above, and over `cldr_fields.jsonl` for CLDR. - **A patronymic** (decision 8): `patronymic`, the tokens whose reading is `PATRONYMIC`, in whichever role the parse put them, with `family` and `middle` unchanged. What fills it is a word or a rule that says patronymic, never a guess from position: the vocabulary's `patronymic` particles (`bin`, `binti`, `ibn`, `bint`, Hebrew `bar` and `bat` and the native-script forms) with the name after them, in the default, so `Mahathir bin Mohamad` reads family `bin Mohamad` as today and patronymic `bin Mohamad` beside it; the East Slavic and Turkic rules where RU or TR_AZ turns them on, so `Ivan Ivanovich Ivanov` under RU keeps middle `Ivanovich` and adds patronymic `Ivanovich`; and an Icelandic ending rule an `is` pack would turn on, the default unable to tell `Karlsdóttir` from the Swedish surname `Johansson`. The name `patronymic` is kept for a matronymic too, which is written the same way and cannot be told from one. **`family` is not emptied** by recognizing a patronymic, revising what this section said before: the people filling in the fielded corpora put the patronymic in the family box (below), as CLDR's samples put an Icelandic patronymic in `surname` or `surname-core` (CLDR's `is` file's 3 Icelandic samples, in all 9 of their renderings; its one foreign sample puts a `-dóttir` word in `surname2`) and a Russian one in `given2` (its 2 `ru` samples, in all 6 renderings), our `middle`, while Wikidata records an Icelandic name as a patronymic with no family name; a `family` that keeps it and a `patronymic` that names it satisfy both. A caller writing "Mr. {family}" reads `patronymic` to see that the family is one. - **A second surname**: `surname2`, the family's second name unit where a rule has read the family as two (rules/O6, which a caller or an `es` pack turns on), and empty otherwise, `family` keeping both units as O6 makes it today. O6 already knows which unit it took, so it marks the second one's words, a private tag that the published reading carries as `Reading.second_surname` (a stable tag, as first written here, would make an O6 parse unequal to its 2.x pickle, `Token` comparing its tags; decisions/R10); a family comma alone marks nothing, since `Lloyd Webber, Andrew` is not two surnames, and a hyphenated family is one token and stays whole. Whether the first unit gets a view of its own, and its name (`surname` sits close to the `surnames` that exists), is a value question below. `surnames` keeps its meaning, `middle` and `family` joined, the reading of a double surname the default splits. -- **`middle`** (#704): keeps its meaning, every word of the given part after the given name, and the given name is one name unit unless a rule joins words into it, as rules/P5 already does (`Abdul Aziz Tariq` → given `Abdul Aziz`). A compound given name (`Maria do Carmo`, `María José`) is the `[rules]` rule above, turned on by a caller or a pack, moving its words into `given` only under that rule. CLDR's `given2` is `middle`, so no view is added. A Sikh `Kaur` stays in `middle`, and so, under FAMILY_FIRST, does an English name written after a Chinese one (`Lim Mei Ling Jessica` → given `Mei`, middle `Ling Jessica`), an accepted cost. +- **`middle`** (#704): keeps its meaning, every word of the given part after the given name, and the given name is one name unit unless a rule joins words into it, as rules/P5 already does (`Abdul Aziz Tariq` → given `Abdul Aziz`). A compound given name (`Maria do Carmo`, `María José`) is the `[rules]` rule above, turned on by a caller or a pack, moving its words into `given` only under that rule: built in 4g as `Policy.two_given_names` (rules/O7), the given name taking the middle unit beside it; a particle chain running to the end of the name (`Maria do Carmo Silva`) stays the family under it, which decisions/O7 leaves Open. CLDR's `given2` is `middle`, so no view is added. A Sikh `Kaur` stays in `middle`, and so, under FAMILY_FIRST, does an English name written after a Chinese one (`Lim Mei Ling Jessica` → given `Mei`, middle `Ling Jessica`), an accepted cost. **Measured in the fielded corpora** (2026-10-28, master `c8357e74`, counts only; recompute by counting each record's boxes by word count and word ending with `local_corpora.read_records`, grouped by the record's country or nationality, and the parse's role for the counted word with `parse()`. The ending tests, case-insensitive on the box's last word: an Icelandic patronymic a one-word family box ending `dóttir`, `dottir`, `sson` or `son` (the role count, `dóttir`, `dottir` or `sson`); an East Slavic patronymic `ovich`, `ovna`, `evich`, `evna`, `ichna` or their Cyrillic forms, in a given box of two or more words; nasab `bin`, `binti`, `bint`, `bte`, `bt`, `b.`, `ibn` or `ben` anywhere in the box. Other tests give other counts by a few percent): @@ -391,7 +391,7 @@ Each sub-phase is one PR, measured on its own base as phase 2's and phase 3's ar | 4d | `surname2`: O6's mark on the second unit's words, carried on `Reading.second_surname`. Landed (decisions/R10's 2026-10-29 entry) | — (neutral) | | 4e | the render defaults and `ParsedName.order`. Landed (decisions/R6's 2026-10-29 entry) | — | | 4f | `[rules]` compiled into a `Locale`'s policy patch; RU and TR_AZ rebuilt from their files. Landed (decisions/vocabulary-model's 2026-10-29 sub-phase 4f entry) | 3b | -| 4g | the compound given name, a rule kind (#704) | 4f | +| 4g | the compound given name, a rule kind (#704): built 2026-10-30, `Policy.two_given_names` (rules/O7) | 4f | | 4h | the shape inventory per order (#469), starting with the title between a family and a given name under `FAMILY_FIRST` | — | **Interleaved with phase 3** (its sub-phases 3a to 3i, above): 4a can start beside 3a, its `languages` waiting for 3a's index; 4d needs nothing of phase 3, and 4b and 4c need 3a's index only for a listed word's kind, a suffix word's and a particle's; they unblock its packs, `cs`'s generations (3f) and `pt-BR`'s agnomens (3h) on 4b, `es` and `pt` (3g) on 4d and 4g; 4f lands with or after 3b, since both decide what a shipped `Locale` holds; an `is` pack, which no phase 3 sub-phase lists, needs 3b, 4c and 4f. 3i, the bearers' languages strict would read, needs 3a alone, and 4e and 4h stand apart from both. So the order is 3a and 4a, then 4b to 4d with 3b and 3c (4b after 3a), then 4f, then the packs with 4g, with 4e at any point and 3i at any point after 3a. diff --git a/docs/locales.rst b/docs/locales.rst index d853536c4..260c17d03 100644 --- a/docs/locales.rst +++ b/docs/locales.rst @@ -478,7 +478,8 @@ by ``tests/v2/test_locales.py``: never imports pack modules). #. Write the rules the pack turns on as data, if they are ``Policy`` fields the vocabulary files' ``[rules]`` table lists - (``patronymic_rules``, ``name_order``, ``two_family_names``): put the + (``patronymic_rules``, ``name_order``, ``two_family_names``, + ``two_given_names``): put the table in the pack's language file, list the file in ``PACK_RULES`` in ``tools/vocab/build.py``, rebuild, and give the pack ``policy=rules_patch("")`` from ``nameparser._locale``, as diff --git a/docs/release_log.rst b/docs/release_log.rst index c6ac573f4..7fa0a3e3d 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -11,6 +11,7 @@ Release Log - **Add ParsedName.generation and ParsedName.credentials, the suffix split by kind.** ``parse("Smith, John, Jr., MD, Esq.")`` gives generation ``Jr.`` and credentials ``MD, Esq.``, where 1.4.0 through 2.8.0 gave only suffix ``Jr., MD, Esq.``, which is unchanged. They are views like ``family_base``: render keys, not fields of ``as_dict()``. An honorific after a name (``Esq.``, ``さん``) is a credential, and a suffix written by ``replace()``, or in a name unpickled from 2.x, is all credentials. ``Token.reading`` reads ``Jr.`` and ``Sr.`` as a generation, and ``Ambiguity.alternative`` of a lone ``Jr`` names a generation where 2.7.0 and 2.8.0 named a post-nominal. :issue:`326`, :issue:`754` - **Add ParsedName.patronymic, the words naming the bearer's parent.** ``parse("Mahathir bin Mohamad")`` gives patronymic ``bin Mohamad`` beside family ``bin Mohamad``, which 1.4.0 through 2.8.0 gave alone and which is unchanged; under ``locales.RU``, ``Ivan Ivanovich Ivanov`` gives patronymic ``Ivanovich`` and keeps it as the middle name, and under ``locales.TR_AZ`` a marker such as ``oglu`` reads with the name before it. A view like ``family_base``, not a field of ``as_dict()``, read only from a patronymic particle (``bin``, ``binti``, ``binte``, ``ibn``, ``bint``, ``bar``, ``bat`` and their Arabic and Hebrew forms) or a rule a locale turns on, so ``Anwar Ibrahim`` has none, and a name written by ``replace()``, or unpickled from 2.x, has none either. ``Ambiguity.alternative`` of a leading ``bin`` read as a given name names a patronymic where 2.7.0 and 2.8.0 named a particle. :issue:`754` - **Add ParsedName.surname2, the second of two family names.** ``Parser(policy=Policy(two_family_names=True)).parse("Gabriel García Márquez")`` gives surname2 ``Márquez`` beside family ``García Márquez``, which 2.8.0 gave alone and which is unchanged. A view like ``family_base``, not a field of ``as_dict()``, filled only where ``two_family_names`` reads the family as two, so the default, a family comma (``Lloyd Webber, Andrew``) and a hyphenated family have none, and so does a name unpickled from 2.x or one whose family ``replace()`` rewrote. ``Reading.second_surname`` marks its words; it is not compared. :issue:`754` + - **Add a switch for names with a compound given name.** ``Parser(policy=Policy(two_given_names=True)).parse("Juan Carlos Pérez")`` gives given ``Juan Carlos``, where 1.4.0 through 2.8.0 gave given ``Juan``, middle ``Carlos``. Off by default: under it a middle name beside the given name joins it (``John Quincy Adams`` gives given ``John Quincy``). With ``two_family_names`` on too, the family takes its word first, so ``María José García López`` reads given ``María José``, family ``García López``. A ``Policy`` pickled by 2.8 loads with it off. :issue:`704` - **Add Token.reading, the reading the parse took for each word.** ``parse("John Smith PhD Jones").tokens[-1].reading`` is ``Reading('credential' as 'suffix', 'position')``, the reading 2.7's ``Ambiguity.alternative`` gave only for the other side of a fork, and ``ReadingKind`` gains ``patronymic``. The field is not compared, and a ``ParsedName`` pickled by 2.x loads with no readings. :issue:`754` - **Add Reading.languages, the language files that list a word the way it was read.** ``parse("Dr. Juan de la Vega III").tokens[2].reading.languages`` is ``('es', 'fr', 'it', 'nl', 'pt')`` for the particle ``de``, and ``Ambiguity.alternative`` names its files too; a reading by shape or position names none. It is not compared, and a ``Reading`` pickled by 2.7 or 2.8 loads with none. The first parse in a process that meets a listed word loads the reading index once: about 1 ms, or about 16 ms where Python writes no bytecode cache. :issue:`754`, :issue:`753` diff --git a/nameparser/_locale.py b/nameparser/_locale.py index f0170343b..aaab6065f 100644 --- a/nameparser/_locale.py +++ b/nameparser/_locale.py @@ -176,6 +176,7 @@ def _tags(value: object) -> frozenset[str]: "name_order": lambda v: _ORDERS[v], "patronymic_rules": lambda v: frozenset(PatronymicRule(r) for r in v), "two_family_names": bool, + "two_given_names": bool, } diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index 8f8b7aa71..798004a29 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -19,15 +19,16 @@ assign's since #613, and behind an empty part before the comma too since #892. Reads: Policy.patronymic_rules, Policy.two_family_names, -Policy.name_order (O6 stands down where the script's order differs -from the declared one), Policy.middle_as_family, +Policy.two_given_names, Policy.name_order (O6 and O7 stand down where +the script's order differs from the declared one), +Policy.middle_as_family, Policy.extra_suffix_delimiters (R1's entry pass, for the delimiter cores group drops); Lexicon.given_name_titles, and Lexicon.suffix_acronyms_ambiguous (a dotted member in a clause, which carries no tag there, S13). Implements rules S13, H1, M4, P1, P6 (its no-comma site), O1, O2, O6, -O3 and R1 of docs/design/rules/; each is cited at its code below, and +O7, O3 and R1 of docs/design/rules/; each is cited at its code below, and H1/P1/O1/O2's history lives in docs/design/decisions/. `suffix_entries` is the R1 entry pass as a state-in/state-out function, for Parser.revise to run over a forced-role sub-parse @@ -46,7 +47,8 @@ is_lone_never_given_particle, settled_by_case, ) from nameparser._pipeline._state import ( - AMBIGUOUS_ACRONYM_TAG, ATTACHED_TAG, NAME_ROLES, SECOND_SURNAME_TAG, + AMBIGUOUS_ACRONYM_TAG, ATTACHED_TAG, NAME_ROLES, PATRONYMIC_TAG, + SECOND_SURNAME_TAG, ParseState, PendingAmbiguity, PendingReading, Structure, WorkToken, _NEVER_FLIPPED, comma_bucket, copy_with, ) @@ -323,6 +325,62 @@ def _take_second_family_name(state: ParseState, _retag(tokens, i, role) +def _take_second_given_name(state: ParseState, + tokens: list[WorkToken]) -> None: + # rules/O7: "the given name takes the middle unit beside it" -- + # O6's mirror, read after O6 has taken its word and before O3 + # folds what is left. In place, as every post rule writes. + # History: decisions/O7 (#704). + # A family comma leaves the given part laid out given-first; without + # one, an order the script set (W4) is not the caller's, as for O6. + if state.structure is Structure.FAMILY_COMMA: + given_first = True + elif state.order is None or state.order != state.policy.name_order: + return + else: + given_first = state.order.index(Role.GIVEN) < state.order.index( + Role.MIDDLE) + given = _idx(tokens, Role.GIVEN) + middle = _idx(tokens, Role.MIDDLE) + # a given name already holding two words keeps what it has: a + # hyphenated one, or a bound given-name pair (P5), counted as O6 + # counts the family + if not given or not middle or _family_word_count(tokens, given) != 1: + return + # the unit beside the given name: the positions lay a given-first + # part out given, middles..., and FAMILY_FIRST_GIVEN_LAST's + # middles..., given, so it is the first middle unit or the last + units = _units(tokens, middle) + take = units[0] if given_first else units[-1] + # ONE name word, counted as O6 counts one, with a letter in it; an + # initial is taken, since it writes a given name. A given name with + # no letter in it is a mark or a number, nothing to complete. + if (_family_word_count(tokens, take) != 1 + or not any(ch.isalpha() for i in take for ch in tokens[i].text) + or not any(ch.isalpha() for i in given for ch in tokens[i].text)): + return + # a patronymic is no given name, on either side of the join: a + # patronymic particle's run, its reading decided at classify (R9's + # vocabulary arm), whether it is the unit taken or the given name + # itself; a word an ending rule a policy turns on reads as one; and + # a Turkic marker's father + if any(PATRONYMIC_TAG in tokens[i].tags for i in take + given): + return + rules = state.policy.patronymic_rules + if rules: + slavic = PatronymicRule.EAST_SLAVIC in rules + turkic = PatronymicRule.TURKIC in rules + after = take[-1] + 1 + if any(slavic and has_patronymic_ending(tokens[i].text) + or turkic and is_patronymic_marker(tokens[i].text) + for i in take) or ( + turkic and after < len(tokens) + and is_patronymic_marker(tokens[after].text)): + return + for i in take: + _retag(tokens, i, Role.GIVEN) + + # rules/R10: "the family's second name unit, the one written second, # makes the second-surname view" -- whichever of the two O6 took # (decisions/R10, #754). It moves the unit into the family as @@ -778,6 +836,8 @@ def post_rules(state: ParseState) -> ParseState: if state.policy.two_family_names: _take_second_family_name(state, tokens) + if state.policy.two_given_names: + _take_second_given_name(state, tokens) # rules/O3: "every middle word joins the family name and is # rendered before it" (v1 handle_middle_name_as_last). v1 diff --git a/nameparser/_policy.py b/nameparser/_policy.py index 504e3ca94..c65b24af5 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -13,7 +13,8 @@ from typing import Any from nameparser._types import ( - FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, Role, _coerce_enum, + ADDED_IN, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, Role, + _coerce_enum, _guarded_getstate, _guarded_setstate, _reject_bare_string_order, _require_iterable, _validated_order, ) @@ -715,6 +716,31 @@ class Policy: #: before middle_as_family, which then folds whatever middles are #: left. two_family_names: bool = False + #: Reads the given name as two name units, for data where a person + #: carries a compound given name and no middle name (rules/O7): + #: "Juan Carlos Pérez" gives given ``Juan Carlos``, family + #: ``Pérez``, and with ``two_family_names`` too, "María José García + #: López" gives given ``María José``, family ``García López``. The + #: middle unit beside the given name joins it -- the one after it, + #: or the one before it under FAMILY_FIRST_GIVEN_LAST with no family + #: comma, after which the part reads given-first -- and any + #: middles past that stay middles; a particle chain the parse left + #: in the middle counts with its word ("María del Carmen de la + #: Vega" gives given ``María del Carmen``), and an initial is taken, + #: a given name written short. A given name already holding two + #: words keeps what it has (a hyphenated or bound pair), the word + #: taken must be one name word, and a word an ending rule reads as a + #: patronymic stays where it is. A family comma leaves the given part + #: to this rule ("Pérez, Juan Carlos"); a name its script reads in an + #: order other than name_order is left alone, as two_family_names + #: leaves it. Runs after two_family_names, which takes its word + #: first ("Gabriel García Márquez" keeps given ``Gabriel``), and + #: before middle_as_family, which then folds whatever middles are + #: left. A name whose second given word is a middle name gains it + #: in the given name ("John Quincy Adams" gives given ``John + #: Quincy``), so it is off by default and reports nothing. + two_given_names: bool = field( + default=False, metadata={ADDED_IN: "3.0.0"}) # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate @@ -924,6 +950,8 @@ class PolicyPatch: unlisted_dotted_suffixes: bool | _Unset = UNSET unlisted_caps_suffixes: CapsSuffixes | _Unset = UNSET two_family_names: bool | _Unset = UNSET + two_given_names: bool | _Unset = field( + default=UNSET, metadata={ADDED_IN: "3.0.0"}) # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate @@ -975,7 +1003,8 @@ def __post_init__(self) -> None: object.__setattr__(self, f.name, frozenset(value)) # middle_as_family, lenient_comma_suffixes, # unlisted_dotted_suffixes, unlisted_caps_suffixes, strip_emoji, - # strip_bidi and two_family_names are scalar (compose="override") + # strip_bidi, two_family_names and two_given_names are scalar + # (compose="override") # fields and DELIBERATELY get no type check here, unlike # name_order and the union fields above: a # PolicyPatch(strip_emoji="off") is constructible, and only raises diff --git a/nameparser/vocab/README.md b/nameparser/vocab/README.md index 804a1e092..53337f531 100644 --- a/nameparser/vocab/README.md +++ b/nameparser/vocab/README.md @@ -271,10 +271,11 @@ patronymic_rules = ["east-slavic"] | `patronymic_rules` | a non-empty array of rule names, each once: `"east-slavic"`, `"turkic"` | `patronymic_rules`, a set of `PatronymicRule` (rules/O1, rules/O2) | | `name_order` | `"family-first"` or `"family-first-given-last"` | `name_order`, the order constant of that name | | `two_family_names` | `true` | `two_family_names` (rules/O6) | +| `two_given_names` | `true` | `two_given_names` (rules/O7) | -A rule is written only to turn it on: `name_order = "given-first"` and `two_family_names = false` are the defaults, and a pack's value overrides the caller's own, so the build refuses both rather than let a pack switch a caller's rule off. The files that hold a table are exactly the ones `PACK_RULES` in `tools/vocab/build.py` lists, each beside the pack that reads it (`tr.toml` is `locales.TR_AZ`'s, one file for Turkish and Azerbaijani, since there is no `az.toml`); a table in any other file is a build error, since nothing would read it, and so is a file listed there without one. Writing a `[rules]` table does not choose the file's vocabulary: that is the pack's `languages`, a separate field. +A rule is written only to turn it on: `name_order = "given-first"`, `two_family_names = false` and `two_given_names = false` are the defaults, and a pack's value overrides the caller's own, so the build refuses all three rather than let a pack switch a caller's rule off. The files that hold a table are exactly the ones `PACK_RULES` in `tools/vocab/build.py` lists, each beside the pack that reads it (`tr.toml` is `locales.TR_AZ`'s, one file for Turkish and Azerbaijani, since there is no `az.toml`); a table in any other file is a build error, since nothing would read it, and so is a file listed there without one. Writing a `[rules]` table does not choose the file's vocabulary: that is the pack's `languages`, a separate field. -A rule KIND the table does not list (a compound given name, a married-name ending) is a change to the parser, not to a file: a new `Policy` field (with `ADDED_IN`), a row in `build.py`'s `RULE_KEYS` and in `_locale.py`'s `_RULE_VALUES`, and a mechanisms.md entry of its own for the rule kind (mechanisms.md#LOCALE-PACKS-PURE-DATA). `tests/v2/test_vocab.py` holds the two tables to each other and to `Policy`. +A rule KIND the table does not list (a married-name ending) is a change to the parser, not to a file: a new `Policy` field (with `ADDED_IN`), a row in `build.py`'s `RULE_KEYS` and in `_locale.py`'s `_RULE_VALUES`, and a mechanisms.md entry of its own for the rule kind (mechanisms.md#LOCALE-PACKS-PURE-DATA). `tests/v2/test_vocab.py` holds the two tables to each other and to `Policy`. ## Names reserved for later phases diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 9d8c4d51c..ea86979c4 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -46,6 +46,8 @@ #: rules/O6's switch, which every row of its own carries (#754). _TWO = Policy(two_family_names=True) +#: rules/O7's switch, likewise (#704). +_GIVEN2 = Policy(two_given_names=True) #: mirrors tools/differential/shapes.py's SHAPES keys; #: test_case_shape_ids_exist_in_the_inventory (test_corpora.py) @@ -628,6 +630,172 @@ def _check_cjk_shape_purity(self) -> None: policy=_TWO, classification="feat(#754)", notes="rules/O6's accepted cost: one family name behind a " "two-word given name is the shape the switch breaks"), + # rules/O7, the two_given_names switch (#704): off by default, so + # every row carries the policy. One row per comma shape and order. + Case("two_given_names_takes_the_middle_after_the_given_name", + "Juan Carlos Pérez", + {"given": "Juan Carlos", "family": "Pérez"}, + policy=_GIVEN2, classification="feat(#704)", + notes="the default reads middle 'Carlos'"), + Case("two_given_names_leaves_the_middles_past_it", + "Ana María José Pérez", + {"given": "Ana María", "middle": "José", "family": "Pérez"}, + policy=_GIVEN2, classification="feat(#704)"), + Case("two_given_names_reads_the_given_part_after_a_family_comma", + "Pérez, Juan Carlos Antonio", + {"given": "Juan Carlos", "middle": "Antonio", "family": "Pérez"}, + policy=_GIVEN2, classification="feat(#704)"), + Case("two_given_names_reads_a_comma_with_nothing_before_it", + ", Juan Carlos", + {"given": "Juan Carlos"}, + policy=_GIVEN2, classification="feat(#704)", + notes="C15: the empty head names no family, and the given part " + "reads as after any family comma"), + Case("two_given_names_reads_the_name_before_a_suffix_comma", + "Juan Carlos Pérez, PhD", + {"given": "Juan Carlos", "family": "Pérez", "suffix": "PhD"}, + policy=_GIVEN2, classification="feat(#704)"), + Case("two_given_names_leaves_a_two_word_name_alone", + "Juan Carlos", + {"given": "Juan", "family": "Carlos"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_takes_the_middle_after_the_given_family_first", + "García Juan Carlos Antonio", + {"given": "Juan Carlos", "middle": "Antonio", "family": "García"}, + policy=Policy(name_order=FAMILY_FIRST, two_given_names=True), + classification="feat(#704)"), + Case("two_given_names_takes_the_middle_before_the_given_given_last", + "García Antonio Juan Carlos", + {"given": "Juan Carlos", "middle": "Antonio", "family": "García"}, + policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST, + two_given_names=True), + classification="feat(#704)", + notes="the unit beside the given name is the one before it, so " + "the two stay in written order"), + Case("two_given_names_reads_a_family_comma_given_first_given_last", + "Pérez, Antonio Juan Carlos", + {"given": "Antonio Juan", "middle": "Carlos", "family": "Pérez"}, + policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST, + two_given_names=True), + classification="feat(#704)", + notes="#954 review: the part after a family comma reads " + "given-first under every order, so the unit after the " + "given name is taken"), + Case("two_given_names_takes_a_particle_chain_left_in_the_middle", + "María del Carmen de la Vega", + {"given": "María del Carmen", "family": "de la Vega"}, + policy=_GIVEN2, classification="feat(#704)", + notes="P2 ended the first chain where the second particle " + "started its own group, so the default reads middle " + "'del Carmen'"), + Case("two_given_names_takes_an_initial", + "J. Carlos Pérez", + {"given": "J. Carlos", "family": "Pérez"}, + policy=_GIVEN2, classification="feat(#704)"), + Case("two_given_names_keeps_a_hyphenated_given_name", + "María-José Ana López", + {"given": "María-José", "middle": "Ana", "family": "López"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_keeps_a_bound_given_name_pair", + "abdul Rahman Ali Khan", + {"given": "abdul Rahman", "middle": "Ali", "family": "Khan"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_leaves_a_hyphenated_middle", + "Ana María-José López", + {"given": "Ana", "middle": "María-José", "family": "López"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_takes_no_number", + "Juan 3 Pérez", + {"given": "Juan", "middle": "3", "family": "Pérez"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_completes_no_given_name_without_a_letter", + "11 Elizabeth Mcguinness", + {"given": "11", "middle": "Elizabeth", "family": "Mcguinness"}, + policy=_GIVEN2, classification="parity", + notes="#704: a PSC filer's forename box holding a house " + "number, found by the corpus run"), + Case("two_given_names_leaves_a_patronymic_an_ending_rule_reads", + "Ivan Ivanovich Ivanov", + {"given": "Ivan", "middle": "Ivanovich", "family": "Ivanov"}, + policy=Policy(patronymic_rules=frozenset( + {PatronymicRule.EAST_SLAVIC}), two_given_names=True), + classification="parity"), + Case("two_given_names_leaves_a_turkic_markers_father", + "Ali Rashid oglu Mammadov", + {"given": "Ali", "middle": "Rashid oglu", "family": "Mammadov"}, + policy=Policy(patronymic_rules=frozenset( + {PatronymicRule.TURKIC}), two_given_names=True), + classification="parity"), + # #954 review: R9's vocabulary arm, the patronymic particle's run; + # each row read given 'Ahmad bin Ali' (and the like) before the + # guard, with the patronymic view still claiming the words + Case("two_given_names_leaves_a_nasab_in_the_middle", + "Ahmad bin Ali bin Hassan", + {"given": "Ahmad", "middle": "bin Ali", "family": "bin Hassan"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_leaves_a_nasab_before_a_nasab_family", + "Mahathir bin Mohamad bin Ali", + {"given": "Mahathir", "middle": "bin Mohamad", "family": "bin Ali"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_leaves_a_binti_in_the_middle", + "Siti binti Ahmad binti Hassan", + {"given": "Siti", "middle": "binti Ahmad", + "family": "binti Hassan"}, + policy=_GIVEN2, classification="fix(#604)", + notes="1.4.0 read middle 'binti Ahmad binti', binti no particle " + "until #604"), + # the third review of #954: the given name itself a patronymic + # particle's run; each row joined the word after it before the + # guard read the given side too + Case("two_given_names_leaves_a_lone_nasab_particle_given", + "bin Ali Hassan", + {"given": "bin", "middle": "Ali", "family": "Hassan"}, + policy=_GIVEN2, classification="parity", + ambiguities=("particle-or-given",)), + Case("two_given_names_leaves_a_nasab_particle_after_a_family_comma", + "Ahmad, bin Ali", + {"given": "bin", "middle": "Ali", "family": "Ahmad"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_leaves_a_binti_particle_after_a_family_comma", + "Siti, binti Ahmad", + {"given": "binti", "middle": "Ahmad", "family": "Siti"}, + policy=_GIVEN2, classification="parity"), + Case("two_given_names_leaves_a_nasab_given_name_given_last", + "Hassan Yusof bin Ali", + {"given": "bin Ali", "middle": "Yusof", "family": "Hassan"}, + policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST, + two_given_names=True), + classification="feat(#704)"), + Case("two_given_names_leaves_a_turkic_marker_it_would_take", + "Ali Vali oglu Mammadov", + {"given": "Vali", "middle": "oglu Mammadov", "family": "Ali"}, + policy=Policy(name_order=FAMILY_FIRST, patronymic_rules=frozenset( + {PatronymicRule.TURKIC}), two_given_names=True), + classification="feat(#704)", + notes="#954 review: the marker inside the unit, the guard's other " + "arm; without it the given name reads 'Vali oglu'"), + Case("two_given_names_leaves_a_script_order_alone", + "王 小 明", + {"given": "小", "middle": "明", "family": "王"}, + policy=_GIVEN2, classification="fix(#271)", + notes="W4 reads the Han name family-first; 1.4.0 read given '王'"), + Case("two_given_names_reads_after_two_family_names", + "María José García López", + {"given": "María José", "family": "García López"}, + policy=Policy(two_family_names=True, two_given_names=True), + classification="feat(#704)", + notes="O6 takes 'García' first; this rule takes 'José' from " + "what is left"), + Case("two_given_names_yields_a_three_word_name_to_two_family_names", + "Gabriel García Márquez", + {"given": "Gabriel", "family": "García Márquez"}, + policy=Policy(two_family_names=True, two_given_names=True), + classification="feat(#704)"), + Case("two_given_names_reads_before_middle_as_family", + "Hassan Mohamad Ali Omar", + {"given": "Hassan Mohamad", "family": "Ali Omar"}, + policy=Policy(two_given_names=True, middle_as_family=True), + classification="feat(#704)"), # MOVED by #289, not deleted: 'MA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and is taken with no # words to spare -- the reserve/count this row used to pin no diff --git a/tests/v2/data/policy_v28.pickle b/tests/v2/data/policy_v28.pickle new file mode 100644 index 000000000..8c707b60d Binary files /dev/null and b/tests/v2/data/policy_v28.pickle differ diff --git a/tests/v2/rules_budget.json b/tests/v2/rules_budget.json index 56eeb7acd..0fbfdc57e 100644 --- a/tests/v2/rules_budget.json +++ b/tests/v2/rules_budget.json @@ -40,6 +40,7 @@ "O4": {"words": 91, "then_words": 57, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "O5": {"words": 478, "then_words": 160, "because_sentences": 2, "except": 6, "precedence": 1, "accepted": 0, "accepted_words": 0}, "O6": {"words": 596, "then_words": 161, "because_sentences": 2, "except": 5, "precedence": 4, "accepted": 7, "accepted_words": 244}, + "O7": {"words": 549, "then_words": 175, "because_sentences": 2, "except": 4, "precedence": 3, "accepted": 6, "accepted_words": 220}, "P1": {"words": 415, "then_words": 287, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 4, "accepted_words": 319}, "P2": {"words": 315, "then_words": 279, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 167}, "P3": {"words": 639, "then_words": 598, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, diff --git a/tests/v2/rules_doc.py b/tests/v2/rules_doc.py index 7b08854de..000d002d8 100644 --- a/tests/v2/rules_doc.py +++ b/tests/v2/rules_doc.py @@ -111,7 +111,8 @@ def has_boundary_or_waiver(self) -> bool: from nameparser._policy import ( # noqa: E402 - FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, CapsSuffixes, Policy) + FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, CapsSuffixes, PatronymicRule, + Policy) #: Named policies example annotations may reference. Grown as #: extraction demands; each addition is a diff to this dict only. @@ -131,6 +132,21 @@ def has_boundary_or_waiver(self) -> bool: name_order=FAMILY_FIRST_GIVEN_LAST, two_family_names=True), "two_family_names+middle_as_family": Policy( two_family_names=True, middle_as_family=True), + #: rules/O7's switch (#704), named after its field as O6's is, + #: with each order, with O6's switch it reads after and with + #: middle_as_family, which reads after it. + "two_given_names": Policy(two_given_names=True), + "family-first+two_given_names": Policy( + name_order=FAMILY_FIRST, two_given_names=True), + "family-first-given-last+two_given_names": Policy( + name_order=FAMILY_FIRST_GIVEN_LAST, two_given_names=True), + "two_family_names+two_given_names": Policy( + two_family_names=True, two_given_names=True), + "two_given_names+middle_as_family": Policy( + two_given_names=True, middle_as_family=True), + "east-slavic+two_given_names": Policy( + patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC}), + two_given_names=True), "maiden-parens": Policy(maiden_delimiters=frozenset({("(", ")")})), "keep-emoji": Policy(strip_emoji=False), "strict-comma-suffixes": Policy(lenient_comma_suffixes=False), diff --git a/tests/v2/test_cases.py b/tests/v2/test_cases.py index fa23311c9..d646eeb1e 100644 --- a/tests/v2/test_cases.py +++ b/tests/v2/test_cases.py @@ -318,10 +318,11 @@ def test_the_field_sweep_sees_the_claims_it_checks() -> None: # King, Queen, Prince and Princess now reporting title-or-name, and # 'Royal Smith', each under three orders; 1448 -> 1451 on 2026-10-28, # #913's 'Chancellor Smith', reporting title-or-name under three - # orders. - assert claims == 1451, ( - f"the field sweep checks {claims} claims, recorded as 1451 on " - f"2026-10-28") + # orders; 1451 -> 1452 on 2026-10-30, #954's 'bin Ali Hassan', which + # carries its own policy, one particle-or-given claim. + assert claims == 1452, ( + f"the field sweep checks {claims} claims, recorded as 1452 on " + f"2026-10-30") #: The kinds that report a fork between two readings of a word, so carry diff --git a/tests/v2/test_policy.py b/tests/v2/test_policy.py index 65f282abc..addaae947 100644 --- a/tests/v2/test_policy.py +++ b/tests/v2/test_policy.py @@ -328,10 +328,10 @@ def test_the_2_3_positional_fields_did_not_move() -> None: names = tuple(f.name for f in dataclasses.fields(cls)) assert names[:len(_FIELDS_AT_2_3)] == _FIELDS_AT_2_3, cls.__name__ # each later cycle's fields appended behind the last: 2.4's two, - # then 2.6's two_family_names + # then 2.6's two_family_names, then 3.0's two_given_names assert names[len(_FIELDS_AT_2_3):] == ( "unlisted_dotted_suffixes", "unlisted_caps_suffixes", - "two_family_names"), cls.__name__ + "two_family_names", "two_given_names"), cls.__name__ # the positional binding itself, not just the names assert Policy(GIVEN_FIRST, (), frozenset(), frozenset(), False, frozenset(), frozenset(), frozenset(), True, @@ -899,3 +899,21 @@ def test_unlisted_caps_suffixes_on_reads_an_all_caps_word() -> None: assert on.parse("John Smith Z").family == default.parse("John Smith Z").family assert on.parse("John Smith XY2").family == "XY2" assert on.parse("John Smith MC").suffix == "MC" + + +def test_a_2_8_policy_pickle_loads_without_two_given_names() -> None: + """tests/v2/data/policy_v28.pickle is `Policy(two_family_names=True)` + pickled (protocol 4) by the released 2.8.0 wheel, from a directory + outside the checkout under `PYTHONSAFEPATH=1 uv run --isolated + --no-project --with 'nameparser==2.8.0'`, the version and module + path printed to show the wheel answered (2026-10-30). 3.0's + `two_given_names` (rules/O7, #704) carries ADDED_IN, so the pickle + loads with the field's default instead of being refused.""" + import pickle + from pathlib import Path + + with open(Path(__file__).parent / "data" / "policy_v28.pickle", + "rb") as f: + policy = pickle.load(f) + assert policy.two_given_names is False + assert policy == Policy(two_family_names=True) diff --git a/tests/v2/test_vocab.py b/tests/v2/test_vocab.py index ccb344ba9..06c63e4f0 100644 --- a/tests/v2/test_vocab.py +++ b/tests/v2/test_vocab.py @@ -250,6 +250,8 @@ def _w(body: str) -> str: ("rules-flag-not-true", {"ru.toml": _w('[rules]\ntwo_family_names = "yes"')}, "must be `true`"), ("rules-flag-an-integer", {"ru.toml": _w('[rules]\ntwo_family_names = 1')}, "must be `true`"), ("rules-flag-default", {"ru.toml": _w('[rules]\ntwo_family_names = false')}, "is the default"), + # 4g's flag (#704), refused as its default like O6's + ("rules-two-given-names-default", {"ru.toml": _w('[rules]\ntwo_given_names = false')}, "is the default"), ("rules-order-default", {"ru.toml": _w('[rules]\nname_order = "given-first"')}, "is the default"), ("pack-file-without-rules", {"ru.toml": OK}, "locales.RU reads its policy patch"), # 1: schema, unknown keys, file names @@ -513,7 +515,8 @@ def test_every_mutation_starts_from_a_pool_the_checks_accept() -> None: # every [rules] key and shape, in a file a pack reads, alone or # beside words {"ru.toml": _w('[rules]\npatronymic_rules = ["east-slavic", "turkic"]\n' - 'name_order = "family-first-given-last"\ntwo_family_names = true')}, + 'name_order = "family-first-given-last"\ntwo_family_names = true\n' + 'two_given_names = true')}, {"tr.toml": _w('[rules]\nname_order = "family-first"\n\n' + OK.removeprefix("schema = 1\n\n"))}, {"ja.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false }'), "zh.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false }')}, @@ -1302,6 +1305,26 @@ def test_the_rule_tables_agree_with_policy() -> None: assert getattr(policy, key) != getattr(default, key), (key, value) +def test_a_two_given_names_table_reaches_the_parse(monkeypatch: pytest.MonkeyPatch) -> None: + # 4g (#704): no shipped pack turns rules/O7 on, so the table is + # compiled from a fixture and read through rules_patch as a pack's + # would be, the compiled module patched for the call + import nameparser.config._rules as compiled + from nameparser import Parser, Policy, PolicyPatch + from nameparser._locale import rules_patch + + pool = build.load({"ru.toml": _w('[rules]\ntwo_given_names = true')}) + namespace: dict[str, Any] = {} + exec(build.render_rules(pool.rules), namespace) + monkeypatch.setattr(compiled, "RULES", namespace["RULES"]) + patch = rules_patch("ru") + assert patch == PolicyPatch(two_given_names=True) + parser = Parser(policy=Policy().patched(patch)) + assert parser.parse("Juan Carlos Pérez").given == "Juan Carlos" + # and the default reading it moved, for the control + assert Parser().parse("Juan Carlos Pérez").given == "Juan" + + def test_the_pack_roster_names_shipped_packs_their_files_compile_into() -> None: from nameparser import locales from nameparser._locale import rules_patch diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index ed30da726..87b5d8a83 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -28,6 +28,7 @@ ", Siti Aminah binti" ".," "1. née Smith" +"11 Elizabeth Mcguinness" "A.B. Vajpayee" "AHMAD IBN-ʿALĪ" "ANH DO" @@ -37,9 +38,11 @@ "Abu Bakar Salim" "Ahmad al-ʿAbbas" "Ahmad bin Abdul Rahman" +"Ahmad bin Ali bin Hassan" "Ahmad bin Dr. Ali" "Ahmad bin “Bob” Ali" "Ahmad ibn-Ali" +"Ahmad, bin Ali" "Alexandre Dumas fils" "Ali Ahmad Vali oglu" "Ali Ahmad Vali oglu Jr." @@ -63,6 +66,7 @@ "Ana María García-López" "Ana María García–López" "Ana María García-López" +"Ana María José Pérez" "Ana María de la Vega Rodríguez" "Ana María van der Berg" "Ana María-José López" @@ -188,6 +192,7 @@ "Dr. John van der Berg" "Dr. Jr née Jones" "Dr. Jr., John" +"Dr. Juan Carlos Pérez Jr." "Dr. Juan Q. Xavier de la Vega III" "Dr. King" "Dr. Mc Donald, John" @@ -239,6 +244,8 @@ "Garcia" "Garcia Juan Carlos" "Garcia Lopez Jr., Juan" +"García Antonio Juan Carlos" +"García Juan Carlos" "García López, Ana" "García Márquez Ana María" "García Márquez Gabriel" @@ -262,6 +269,7 @@ "Gyani Zail Singh" "Hans „Erster“ und “Zweiter” Müller" "Hassan Mohamad Ali" +"Hassan Mohamad Ali Omar" "Hassan, Mohamad Ahmad Ali" "Her Majesty Queen Elizabeth" "Her Royal Highness Princess Anne" @@ -279,6 +287,7 @@ "J NEE SMITH" "J née Smith" "J-E-P DUPONT" +"J. Carlos Pérez" "J. Smith" "J. née Jones Smith V" "J. née Smith" @@ -412,6 +421,7 @@ "John PhD Smith" "John Pharm D" "John Prof. MA" +"John Quincy Adams" "John Quincy Smith i" "John Smith" "John Smith (Ed)" @@ -644,8 +654,13 @@ "José Silva Júnior" "Jr." "Juan & Garcia" +"Juan 3 Pérez" +"Juan Carlos" "Juan Carlos Pérez" +"Juan Carlos Pérez née López" +"Juan Carlos Pérez, PhD" "Juan McDonald" +"Juan Pérez" "Juan Pérez H." "Juan Pérez de la Vega" "Juan Velasquez y Garcia" @@ -700,6 +715,8 @@ "Maria Kowalska z domu Nowak" "Maria Luisa y de la Cruz" "Maria López née García Pérez" +"Maria do Carmo Silva" +"Maria do Carmo da Silva" "Marie FILS" "Marie Fils" "Marquess of Bath" @@ -714,6 +731,9 @@ "Mary Jane Pope." "Mary Smith née Jones Prof." "Mary-​Jane Smith" +"María José García López" +"María del Carmen de la Vega" +"María-José Ana López" "Mc Donald" "Md Abdul Karim" "Mehmet Oğlu" @@ -793,6 +813,8 @@ "Prof. Dr. Brühl, Ada" "Prof. Smith, Dr. John" "Prof. Smith, John, PhD" +"Pérez, Antonio Juan Carlos" +"Pérez, Juan Carlos" "Pérez, Juan Ó." "QC MP" "Queen Elizabeth II" @@ -1000,6 +1022,7 @@ "abdul Jr Smith Berg" "abdul Ma Smith" "abdul Ph. D. Smith Berg" +"abdul Rahman Ali Khan" "abdul Salam, Ed" "abdul Sir Smith Berg" "abdul Smith Berg Ma" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 25c81fe31..2b4dfe148 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -36,6 +36,7 @@ {"name": ".,"} {"name": "1 & 2, 3 4 5, Mr.", "title": "Mr.", "given": "3", "middle": "4 5", "family": "1 & 2", "initials": "3. 4. 5. 1. 2."} {"name": "1. née Smith", "given": "1.", "maiden": "Smith", "initials": "1."} +{"name": "11 Elizabeth Mcguinness", "given": "11", "middle": "Elizabeth", "family": "Mcguinness", "initials": "1. E. M.", "capitalized_force": "11 Elizabeth McGuinness"} {"name": "A. D.", "given": "A.", "family": "D.", "initials": "A. D."} {"name": "A. R., Sr. Maria García Rosa de la Cruz Jr.", "title": "Sr.", "given": "Maria", "middle": "García Rosa de la Cruz", "family": "A. R.", "suffix": "Jr.", "initials": "M. G. R. C. A. R.", "generation": "Jr."} {"name": "A. R., Sr. de la Cruz Maria García Rosa Jr.", "order": "FAMILY_FIRST", "title": "Sr.", "family": "A. R. de la Cruz Maria García Rosa", "suffix": "Jr.", "initials": "A. R. C. M. G. R.", "generation": "Jr."} @@ -114,9 +115,11 @@ {"name": "Ahmad al-ʿAbbas", "given": "Ahmad", "family": "al-ʿAbbas", "initials": "A. a."} {"name": "Ahmad ben Husain", "given": "Ahmad", "middle": "ben", "family": "Husain", "initials": "A. b. H.", "capitalized_force": "Ahmad Ben Husain"} {"name": "Ahmad bin Abdul Rahman", "given": "Ahmad", "family": "bin Abdul Rahman", "initials": "A. A. R.", "patronymic": "bin Abdul Rahman"} +{"name": "Ahmad bin Ali bin Hassan", "given": "Ahmad", "middle": "bin Ali", "family": "bin Hassan", "initials": "A. A. H.", "patronymic": "bin Ali bin Hassan"} {"name": "Ahmad bin Dr. Ali", "given": "Ahmad", "family": "bin Dr. Ali", "initials": "A. D. A.", "patronymic": "bin Dr. Ali"} {"name": "Ahmad bin “Bob” Ali", "given": "Ahmad", "family": "bin Ali", "nickname": "Bob", "initials": "A. A.", "patronymic": "bin Ali"} {"name": "Ahmad ibn-Ali", "given": "Ahmad", "family": "ibn-Ali", "initials": "A. i."} +{"name": "Ahmad, bin Ali", "given": "bin", "middle": "Ali", "family": "Ahmad", "initials": "b. A. A.", "capitalized_force": "Bin Ali Ahmad"} {"name": "Aina Kalniņa", "given": "Aina", "family": "Kalniņa", "initials": "A. K."} {"name": "Aishwarya Rai", "given": "Aishwarya", "family": "Rai", "initials": "A. R."} {"name": "Aishwarya Rai Bachchan", "given": "Aishwarya", "middle": "Rai", "family": "Bachchan", "initials": "A. R. B."} @@ -205,6 +208,7 @@ {"name": "Ana María García-López", "given": "Ana", "middle": "María", "family": "García-López", "initials": "A. M. G."} {"name": "Ana María García–López", "given": "Ana", "middle": "María", "family": "García–López", "initials": "A. M. G."} {"name": "Ana María García-López", "given": "Ana", "middle": "María", "family": "García-López", "initials": "A. M. G."} +{"name": "Ana María José Pérez", "given": "Ana", "middle": "María José", "family": "Pérez", "initials": "A. M. J. P."} {"name": "Ana María de la Vega Rodríguez", "given": "Ana", "middle": "María", "family": "de la Vega Rodríguez", "initials": "A. M. V. R."} {"name": "Ana María van der Berg", "given": "Ana", "middle": "María", "family": "van der Berg", "initials": "A. M. B."} {"name": "Ana María-José López", "given": "Ana", "middle": "María-José", "family": "López", "initials": "A. M. L."} @@ -819,6 +823,7 @@ {"name": "Dr. John van der Berg", "title": "Dr.", "given": "John", "family": "van der Berg", "initials": "J. B."} {"name": "Dr. Jr née Jones", "title": "Dr.", "given": "Jr", "middle": "née", "family": "Jones", "initials": "J. n. J.", "capitalized_force": "Dr. Jr Née Jones"} {"name": "Dr. Jr., John", "given": "John", "family": "Dr.", "suffix": "Jr.", "initials": "J. D.", "generation": "Jr."} +{"name": "Dr. Juan Carlos Pérez Jr.", "title": "Dr.", "given": "Juan", "middle": "Carlos", "family": "Pérez", "suffix": "Jr.", "initials": "J. C. P.", "generation": "Jr."} {"name": "Dr. Juan Q. Velasquez y Garcia", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} {"name": "Dr. Juan Q. Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G.", "generation": "III"} {"name": "Dr. Juan Q. Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G.", "generation": "Jr."} @@ -1107,6 +1112,8 @@ {"name": "Garcia de Jalon", "given": "Garcia", "family": "de Jalon", "initials": "G. J."} {"name": "Garcia y Lopez", "given": "Garcia", "middle": "y", "family": "Lopez", "initials": "G. y. L."} {"name": "Garcia-Roza, Luiz Alfredo", "given": "Luiz", "middle": "Alfredo", "family": "Garcia-Roza", "initials": "L. A. G."} +{"name": "García Antonio Juan Carlos", "given": "García", "middle": "Antonio Juan", "family": "Carlos", "initials": "G. A. J. C."} +{"name": "García Juan Carlos", "given": "García", "middle": "Juan", "family": "Carlos", "initials": "G. J. C."} {"name": "García López, Ana", "given": "Ana", "family": "García López", "initials": "A. G. L."} {"name": "García Márquez Ana María", "given": "García", "middle": "Márquez Ana", "family": "María", "initials": "G. M. A. M."} {"name": "García Márquez Gabriel", "given": "García", "middle": "Márquez", "family": "Gabriel", "initials": "G. M. G."} @@ -1253,6 +1260,7 @@ {"name": "Hassan", "order": "FAMILY_FIRST", "family": "Hassan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "H."} {"name": "Hassan B. Yusof", "given": "Hassan", "middle": "B.", "family": "Yusof", "initials": "H. B. Y."} {"name": "Hassan Mohamad Ali", "given": "Hassan", "middle": "Mohamad", "family": "Ali", "initials": "H. M. A."} +{"name": "Hassan Mohamad Ali Omar", "given": "Hassan", "middle": "Mohamad Ali", "family": "Omar", "initials": "H. M. A. O."} {"name": "Hassan bin Yusof", "given": "Hassan", "family": "bin Yusof", "initials": "H. Y.", "patronymic": "bin Yusof"} {"name": "Hassan, Mohamad Ahmad Ali", "given": "Mohamad", "middle": "Ahmad Ali", "family": "Hassan", "initials": "M. A. A. H."} {"name": "Haukur Ágústsson", "given": "Haukur", "family": "Ágústsson", "initials": "H. Á."} @@ -1388,6 +1396,7 @@ {"name": "J née Smith", "given": "J", "maiden": "Smith", "initials": "J."} {"name": "J-E-P DUPONT", "given": "J-E-P", "family": "DUPONT", "initials": "J. D.", "capitalized": "J-e-P Dupont"} {"name": "J. A. D.", "given": "J.", "middle": "A.", "family": "D.", "initials": "J. A. D."} +{"name": "J. Carlos Pérez", "given": "J.", "middle": "Carlos", "family": "Pérez", "initials": "J. C. P."} {"name": "J. None D.", "given": "J.", "middle": "None", "family": "D.", "initials": "J. N. D."} {"name": "J. S. V.", "given": "J.", "middle": "S.", "family": "V.", "initials": "J. S. V."} {"name": "J. Smith", "given": "J.", "family": "Smith", "initials": "J. S."} @@ -1706,6 +1715,7 @@ {"name": "John Pharm D", "given": "John", "middle": "Pharm", "family": "D", "initials": "J. P. D."} {"name": "John Prof. MA", "title": "Prof.", "family": "John", "suffix": "MA", "initials": "J."} {"name": "John Q. Smith", "given": "John", "middle": "Q.", "family": "Smith", "initials": "J. Q. S."} +{"name": "John Quincy Adams", "given": "John", "middle": "Quincy", "family": "Adams", "initials": "J. Q. A."} {"name": "John Quincy Smith i", "given": "John", "middle": "Quincy", "family": "Smith", "suffix": "i", "initials": "J. Q. S.", "capitalized_force": "John Quincy Smith I", "generation": "i"} {"name": "John Smith", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith (Ed)", "given": "John", "family": "Smith", "nickname": "Ed", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S."} @@ -2060,11 +2070,15 @@ {"name": "Jr. Van Johnson", "title": "Jr.", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J."} {"name": "Jr., PhD", "family": "Jr.", "suffix": "PhD", "initials": "J."} {"name": "Juan & Garcia", "given": "Juan & Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J. G."} +{"name": "Juan 3 Pérez", "given": "Juan", "middle": "3", "family": "Pérez", "initials": "J. 3. P."} {"name": "Juan Anglada y Ruiz", "given": "Juan", "family": "Anglada y Ruiz", "initials": "J. A. R."} {"name": "Juan Antonio Rascón Navarro Seña y Redondo", "given": "Juan", "middle": "Antonio Rascón Navarro", "family": "Seña y Redondo", "initials": "J. A. R. N. S. R."} {"name": "Juan Antonio de los Heros Fernández", "given": "Juan", "middle": "Antonio", "family": "de los Heros Fernández", "initials": "J. A. H. F."} {"name": "Juan C. Garaikoetxea Urriza", "given": "Juan", "middle": "C. Garaikoetxea", "family": "Urriza", "initials": "J. C. G. U."} +{"name": "Juan Carlos", "given": "Juan", "family": "Carlos", "initials": "J. C."} {"name": "Juan Carlos Pérez", "given": "Juan", "middle": "Carlos", "family": "Pérez", "initials": "J. C. P."} +{"name": "Juan Carlos Pérez née López", "given": "Juan", "middle": "Carlos", "family": "Pérez", "maiden": "López", "initials": "J. C. P."} +{"name": "Juan Carlos Pérez, PhD", "given": "Juan", "middle": "Carlos", "family": "Pérez", "suffix": "PhD", "initials": "J. C. P."} {"name": "Juan Carlos Rodríguez Agudelo", "given": "Juan", "middle": "Carlos Rodríguez", "family": "Agudelo", "initials": "J. C. R. A."} {"name": "Juan Cárdenas Arroyo", "given": "Juan", "middle": "Cárdenas", "family": "Arroyo", "initials": "J. C. A."} {"name": "Juan Fernández Bazán", "given": "Juan", "middle": "Fernández", "family": "Bazán", "initials": "J. F. B."} @@ -2074,6 +2088,7 @@ {"name": "Juan Garcia y Lopez y", "given": "Juan", "family": "Garcia y Lopez y", "initials": "J. G. L."} {"name": "Juan Harguindey Broussain", "given": "Juan", "middle": "Harguindey", "family": "Broussain", "initials": "J. H. B."} {"name": "Juan McDonald", "given": "Juan", "family": "McDonald", "initials": "J. M."} +{"name": "Juan Pérez", "given": "Juan", "family": "Pérez", "initials": "J. P."} {"name": "Juan Pérez H.", "given": "Juan", "middle": "Pérez", "family": "H.", "initials": "J. P. H."} {"name": "Juan Pérez de la Vega", "given": "Juan", "middle": "Pérez", "family": "de la Vega", "initials": "J. P. V."} {"name": "Juan Q. Velasquez y Garcia", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} @@ -2489,6 +2504,8 @@ {"name": "Maria Popescu", "given": "Maria", "family": "Popescu", "initials": "M. P."} {"name": "Maria Silva", "given": "Maria", "family": "Silva", "initials": "M. S."} {"name": "Maria Wijaya", "given": "Maria", "family": "Wijaya", "initials": "M. W."} +{"name": "Maria do Carmo Silva", "given": "Maria", "family": "do Carmo Silva", "initials": "M. C. S."} +{"name": "Maria do Carmo da Silva", "given": "Maria", "middle": "do Carmo", "family": "da Silva", "initials": "M. C. S."} {"name": "Mariam Geedi Hamish Hussein", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} {"name": "Mariana Duque Mariño", "given": "Mariana", "middle": "Duque", "family": "Mariño", "initials": "M. D. M."} {"name": "Marianne Schmidt", "given": "Marianne", "family": "Schmidt", "initials": "M. S."} @@ -2542,11 +2559,14 @@ {"name": "Mary Sue Hamish Watson", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} {"name": "Mary-​Jane Smith", "given": "Mary-", "middle": "Jane", "family": "Smith", "initials": "M. J. S."} {"name": "María Dolores de Pablos", "given": "María", "middle": "Dolores", "family": "de Pablos", "initials": "M. D. P."} +{"name": "María José García López", "given": "María", "middle": "José García", "family": "López", "initials": "M. J. G. L."} {"name": "María Losada Estévez", "given": "María", "middle": "Losada", "family": "Estévez", "initials": "M. L. E."} {"name": "María Paz Alonso Romero", "given": "María", "middle": "Paz Alonso", "family": "Romero", "initials": "M. P. A. R."} {"name": "María Rubio Tejero", "given": "María", "middle": "Rubio", "family": "Tejero", "initials": "M. R. T."} {"name": "María de la O Lejárraga y García", "given": "María", "family": "de la O Lejárraga y García", "initials": "M. O. L. G."} +{"name": "María del Carmen de la Vega", "given": "María", "middle": "del Carmen", "family": "de la Vega", "initials": "M. C. V."} {"name": "María del Pilar Hurtado Montaño", "given": "María", "family": "del Pilar Hurtado Montaño", "initials": "M. P. H. M."} +{"name": "María-José Ana López", "given": "María-José", "middle": "Ana", "family": "López", "initials": "M. A. L."} {"name": "Masalu Adam Juma Agape Shukurani MB", "order": "FAMILY_FIRST", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "suffix": "MB", "initials": "A. J. A. S. M."} {"name": "Masalu, Adam Juma Agape Shukurani", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "initials": "A. J. A. S. M."} {"name": "Mathilde Therese von Thurn und Taxis", "given": "Mathilde", "middle": "Therese", "family": "von Thurn und Taxis", "initials": "M. T. T. T."} @@ -2954,6 +2974,8 @@ {"name": "Prop. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS", "title": "Prop. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "Pèrs, Ciro di", "given": "Ciro", "family": "di Pèrs", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)", "PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. P."} {"name": "Pérez Lucía", "order": "FAMILY_FIRST", "given": "Lucía", "family": "Pérez", "initials": "L. P."} +{"name": "Pérez, Antonio Juan Carlos", "given": "Antonio", "middle": "Juan Carlos", "family": "Pérez", "initials": "A. J. C. P."} +{"name": "Pérez, Juan Carlos", "given": "Juan", "middle": "Carlos", "family": "Pérez", "initials": "J. C. P."} {"name": "Pérez, Juan Ó.", "given": "Juan", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} {"name": "Pérez, Lucía", "given": "Lucía", "family": "Pérez", "initials": "L. P."} {"name": "Péricourt Louise", "order": "FAMILY_FIRST", "given": "Louise", "family": "Péricourt", "initials": "L. P."} @@ -3783,6 +3805,7 @@ {"name": "abdul Jr Smith Berg", "given": "abdul", "middle": "Jr Smith", "family": "Berg", "initials": "a. J. S. B.", "capitalized_force": "Abdul Jr Smith Berg"} {"name": "abdul Ma Smith", "given": "abdul Ma", "family": "Smith", "initials": "a. M. S.", "capitalized_force": "Abdul Ma Smith"} {"name": "abdul Ph. D. Smith Berg", "given": "abdul", "middle": "Smith", "family": "Berg", "suffix": "Ph. D.", "initials": "a. S. B.", "capitalized_force": "Abdul Smith Berg Ph. D."} +{"name": "abdul Rahman Ali Khan", "given": "abdul Rahman", "middle": "Ali", "family": "Khan", "initials": "a. R. A. K.", "capitalized_force": "Abdul Rahman Ali Khan"} {"name": "abdul Salam, Ed", "given": "abdul", "family": "Salam", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "a. S.", "capitalized_force": "Abdul Salam, ED"} {"name": "abdul Sir Smith Berg", "given": "abdul Sir", "middle": "Smith", "family": "Berg", "initials": "a. S. S. B.", "capitalized_force": "Abdul Sir Smith Berg"} {"name": "abdul Smith Berg Ma", "given": "abdul Smith", "middle": "Berg", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "a. S. B. M.", "capitalized_force": "Abdul Smith Berg Ma"} diff --git a/tools/vocab/build.py b/tools/vocab/build.py index 9420f6e38..b6ac96598 100644 --- a/tools/vocab/build.py +++ b/tools/vocab/build.py @@ -289,11 +289,14 @@ def _alnum(text: str) -> str: "name_order": ("choice", frozenset({"family-first", "family-first-given-last"})), "patronymic_rules": ("set", frozenset({"east-slavic", "turkic"})), "two_family_names": ("flag", frozenset()), + "two_given_names": ("flag", frozenset()), } #: The value a scalar key would restate: a pack's patch overrides the #: caller's own scalar, so writing the default would turn a caller's #: rule off rather than a rule on. -RULE_DEFAULTS: dict[str, object] = {"name_order": "given-first", "two_family_names": False} +RULE_DEFAULTS: dict[str, object] = { + "name_order": "given-first", "two_family_names": False, + "two_given_names": False} #: The files whose `[rules]` a shipped pack reads: the file's tag -> the #: nameparser.locales attribute whose policy patch it is. A `[rules]` #: table elsewhere would compile into nothing a parser reads, so the