From 58d60d62886b10ce507a085ee902d95d3905ef3c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 19:55:17 -0700 Subject: [PATCH 1/6] Add ParsedName.patronymic, the words naming the bearer's parent (3.0, 4c) A patronymic particle the shipped vocabulary holds (bin, binti, ibn, bint, bar, bat and the native-script forms) and the words after it in its field read `patronymic`, and so do a middle word carrying the East Slavic ending and a Turkic marker with the name before it, wherever RU or TR_AZ turns the rule on. The view is a render key, not a field: family and middle keep the words. Classify tags the particle's kind from the reading index and assemble drops the tag, so 2.x pickles still compare equal. rules/R9, decisions/R9; the snapshot and the agreement records gain the view. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- docs/customize.rst | 3 +- docs/design/decisions/R9-patronymic-view.md | 23 + docs/design/decisions/README.md | 1 + docs/design/decisions/vocabulary-model.md | 3 +- docs/design/rules/R9-patronymic-view.md | 77 + docs/design/rules/README.md | 1 + docs/design/vocabulary-model-rfc.md | 6 +- docs/locales.rst | 11 +- docs/release_log.rst | 1 + docs/usage.rst | 13 + nameparser/_pipeline/AGENTS.md | 2 +- nameparser/_pipeline/_assemble.py | 116 +- nameparser/_pipeline/_assign.py | 6 +- nameparser/_pipeline/_classify.py | 15 +- nameparser/_pipeline/_post_rules.py | 23 +- nameparser/_pipeline/_state.py | 7 + nameparser/_pipeline/_vocab.py | 62 +- nameparser/_policy.py | 7 +- nameparser/_reading_index.py | 9 + nameparser/_render.py | 2 +- nameparser/_types.py | 37 +- nameparser/locales/ru.py | 9 +- nameparser/locales/tr_az.py | 8 +- tests/test_east_slavic_patronymic_order.py | 4 +- tests/test_turkic_patronymic_order.py | 2 +- .../v2/data/parsedname_v28_patronymic.pickle | Bin 0 -> 352 bytes tests/v2/pipeline/test_assemble.py | 65 +- tests/v2/rules_budget.json | 1 + tests/v2/rules_doc.py | 2 +- tests/v2/test_cases.py | 9 +- tests/v2/test_cldr_agreement.py | 15 +- tests/v2/test_locales.py | 16 +- tests/v2/test_reading_index.py | 16 + tests/v2/test_regex_sync.py | 16 +- tests/v2/test_snapshot.py | 19 +- tests/v2/test_types.py | 91 + tools/differential/README.md | 5 +- tools/differential/cldr_agreement.py | 26 +- tools/differential/conventions_agreement.json | 27 + tools/differential/corpus_rules.jsonl | 21 + tools/differential/snapshot.jsonl | 68 +- tools/differential/snapshot.py | 13 +- tools/differential/wikidata_agreement.json | 2026 +++++++++++++++++ .../wikidata_native_agreement.json | 119 + 45 files changed, 2874 insertions(+), 133 deletions(-) create mode 100644 docs/design/decisions/R9-patronymic-view.md create mode 100644 docs/design/rules/R9-patronymic-view.md create mode 100644 tests/v2/data/parsedname_v28_patronymic.pickle diff --git a/AGENTS.md b/AGENTS.md index 8076124c6..9b4670dc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Counting claims.** A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name. -**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. `uv run python tools/differential/snapshot.py --since v` prints every corpus name the release moved, grouped by changed field; v2.4.1 is the first tag carrying a snapshot, and for an older release `compare.py --baseline ` prints the same report against the PyPI wheel, closing with the entries compared and how many moved names are Latin-only. Both say how many moved names only `corpus_cldr.jsonl` holds. The snapshot records the seven role fields, ambiguity kinds, `initials()`, the two case-repair views (`capitalized()` and `capitalized(force=True)`, each only where it changes the text, #826) and, since 3.0, the suffix's `generation` and `credentials` views (where the suffix holds a generation, rules/R8) for every name, so an initials, case or generation count is read off it directly -- with two limits. A case view holds the whole repaired name, so in a name repair changes, a role move that changes the rendering moves the view too, with or without a word recased: read a view move that comes with a role move by its words' case, not by whether it moved (#808's `John Smith, otr/l` moved both, `'John Smith OTR/L'` -> `'Otr/L John Smith'`). And `--since` a tag whose snapshot predates #826 (v2.5.0 and earlier) does not compare the views at all, so 2.6.0's case claims come from `compare.py --baseline 2.5.0`. `corpus_cldr.jsonl`'s fictional renderings are in it: count them out of a release figure (both tools print how many moved names only that file holds), or say the figure includes them. A render view the snapshot does not record -- `str()` under a custom format, `initials()` under a custom spec -- stays invisible to it, and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. The DEFAULT `str()` is mostly invisible too, the snapshot seeing it only through a case view where repair changes the text: #754's render defaults moved `str()` for 1,034 corpus entries and the case views for 159, so a change to the default rendering is measured by rendering every entry in both trees (decisions/R6's 2026-10-29 entry holds the recipe). A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's quotable text is its fields' prose (When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets), and its fences and the footer below `---`, where the `Signal:` line stands, are not; `rules/H` names the H section, whose Background is quotable; a decisions citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. **A citation comment holds three things: the quote, a line saying which clause this code implements, and `decisions/X (#N)` pointers to the entries that hold the history.** A code-local reason a reader of that line needs may stay beside them (why an index is safe to keep, why a test runs last), but issue history, measurements and restated examples belong in decisions/, where they are dated and kept current; in a comment they are a second copy that goes stale where nobody looks. #760 trimmed five blocks of 59 to 100 lines that had grown that way (`_post_rules._mark_suffix_entries`, group's P3 both-sides test, its S2/P2/P4 chain head and S4 chain emitter, and the honorific peel's scope in `_script_segment`), moving into dated decisions bullets what those entries lacked. Two of the moved claims no longer held: a policy example that had stopped reaching its case, and a credential said to land as a title that lands as a suffix. +**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. `uv run python tools/differential/snapshot.py --since v` prints every corpus name the release moved, grouped by changed field; v2.4.1 is the first tag carrying a snapshot, and for an older release `compare.py --baseline ` prints the same report against the PyPI wheel, closing with the entries compared and how many moved names are Latin-only. Both say how many moved names only `corpus_cldr.jsonl` holds. The snapshot records the seven role fields, ambiguity kinds, `initials()`, the two case-repair views (`capitalized()` and `capitalized(force=True)`, each only where it changes the text, #826) and, since 3.0, the suffix's `generation` and `credentials` views (where the suffix holds a generation, rules/R8) and the `patronymic` view (where it is not empty, rules/R9) for every name, so an initials, case, generation or patronymic count is read off it directly -- with two limits. A case view holds the whole repaired name, so in a name repair changes, a role move that changes the rendering moves the view too, with or without a word recased: read a view move that comes with a role move by its words' case, not by whether it moved (#808's `John Smith, otr/l` moved both, `'John Smith OTR/L'` -> `'Otr/L John Smith'`). And `--since` a tag whose snapshot predates #826 (v2.5.0 and earlier) does not compare the views at all, so 2.6.0's case claims come from `compare.py --baseline 2.5.0`. `corpus_cldr.jsonl`'s fictional renderings are in it: count them out of a release figure (both tools print how many moved names only that file holds), or say the figure includes them. A render view the snapshot does not record -- `str()` under a custom format, `initials()` under a custom spec -- stays invisible to it, and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. The DEFAULT `str()` is mostly invisible too, the snapshot seeing it only through a case view where repair changes the text: #754's render defaults moved `str()` for 1,034 corpus entries and the case views for 159, so a change to the default rendering is measured by rendering every entry in both trees (decisions/R6's 2026-10-29 entry holds the recipe). A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's quotable text is its fields' prose (When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets), and its fences and the footer below `---`, where the `Signal:` line stands, are not; `rules/H` names the H section, whose Background is quotable; a decisions citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. **A citation comment holds three things: the quote, a line saying which clause this code implements, and `decisions/X (#N)` pointers to the entries that hold the history.** A code-local reason a reader of that line needs may stay beside them (why an index is safe to keep, why a test runs last), but issue history, measurements and restated examples belong in decisions/, where they are dated and kept current; in a comment they are a second copy that goes stale where nobody looks. #760 trimmed five blocks of 59 to 100 lines that had grown that way (`_post_rules._mark_suffix_entries`, group's P3 both-sides test, its S2/P2/P4 chain head and S4 chain emitter, and the honorific peel's scope in `_script_segment`), moving into dated decisions bullets what those entries lacked. Two of the moved claims no longer held: a policy example that had stopped reaching its case, and a credential said to land as a title that lands as a suffix. **Writing the user docs (docs/*.rst) has its own AGENTS.md too.** `docs/AGENTS.md` carries the style the docs are written in — tables as indexes, subheadings per task, measured claims, recipe doctests — distilled from PR #588. It loads the same way as the docs/design/ one below; if your tool does not do nested discovery, read it before editing a `.rst` file under docs/. @@ -227,7 +227,7 @@ The `config/` SETS are frozen since 2.2 (#293): there is no `.add()`/`.remove()` The 2.0 rewrite landed as underscore-private modules beside the v1 code, which 2.0 and then 3.0 deleted. These conventions apply to all of it. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked `docs/superpowers/specs/`; this section is the enforceable subset. **A commit that establishes or amends one of these conventions must update this section in the same commit** — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop. - **Module layout**: every module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_reading_index.py`, `_pipeline/`, `_parser.py`). The public import surface is exactly `nameparser`, `nameparser.locales` and the vocabulary data modules under `nameparser.config`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic. -- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only, e.g. `config/maiden_markers.py`); `_reading_index` imports nothing internal at module level and the index, `config/_index.py`, at call time, since `tools/vocab/build.py` loads it by path; `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types`, `_lexicon` (for `Lexicon.default()` and `_normalize`) and `_policy` (for the scripts that write no initials); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers, `_assemble` also `_reading_index`, for `Reading.languages`, and `_vocab` too, for a listed suffix word's kind (3.0's 4b); `_parser` sits on everything except `_render`, `_reading_index` at call time only, where a choice of languages is applied; `__main__` imports only the public package; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. +- **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only, e.g. `config/maiden_markers.py`); `_reading_index` imports nothing internal at module level and the index, `config/_index.py`, at call time, since `tools/vocab/build.py` loads it by path; `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types`, `_lexicon` (for `Lexicon.default()` and `_normalize`) and `_policy` (for the scripts that write no initials); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers, `_assemble` also `_reading_index`, for `Reading.languages`, and `_vocab` too, for a listed suffix word's kind and a patronymic particle's (3.0's 4b and 4c); `_parser` sits on everything except `_render`, `_reading_index` at call time only, where a choice of languages is applied; `__main__` imports only the public package; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. - **Canonical field order** — the seven roles in `Role` enum declaration order, defined once and derived everywhere (properties, `as_dict`, reprs, `comparison_key`). Never restate the order literally. `Role` is a `StrEnum`: members compare as their field-name strings, and `tokens_for()` coerces strings via `_coerce_enum`. - **Method organization**, fixed section order in every class: fields + `__post_init__` validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head. - **Validation is eager and fail-loud**: every `raise` states the offending value, the expected form, and the fix. Exception taxonomy: wrong type — including wrong element type inside a collection, bare `str` where an iterable of strings is expected, or a `Mapping` where a plain iterable is expected — raises `TypeError`; well-typed but unacceptable values raise `ValueError`; failed enum lookups stay `ValueError` for any input (stdlib `EnumType` precedent). **When the message hands the reader code to paste, that code has to survive a type checker, and then RUN** — nameparser ships `py.typed`, and a placeholder can type-check and still raise: the 2.4 release sweep's first fix to the `capitalization_exceptions` hint offered `('key', 'Value')`, which passes mypy and fails #459's mask check, so `test_add_capitalization_exceptions_raises_pointing_at_replace` now executes the offered code. #337's segmenterless warning offered `Policy(segment_scripts=())`, an `arg-type` error, because these fields are annotated with what they STORE rather than everything the constructor accepts. Prefer the `frozenset()` / `()` spellings in messages and docstrings, and pin the offered spelling in a test — the warning tests matched on `ja_segmenter` and never checked the actionable half of the message. **A warning emitted in `Parser.__post_init__` needs `parser_for` to re-emit it from its own frame** (the `catch_warnings(record=True)` block at its return): `__post_init__`'s `stacklevel` is sized for direct `Parser(...)` construction, and through `parser_for`'s extra frame the default one-line rendering attributes the warning to the library's own `return Parser(...)` — the exact call the message tells the user to change becomes invisible. No single stacklevel serves both entry points; a new construction warning gets the re-emission for free, but a new CONSTRUCTION SITE for `Parser` inside this package needs its own re-emission or its callers get library-attributed warnings (#337 review). diff --git a/docs/customize.rst b/docs/customize.rst index d685bccdf..058bfe0c4 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -681,7 +681,8 @@ listed below. Slavic formal order (``EAST_SLAVIC``) or Turkic reversed order (``TURKIC``) — but stands down under a declared ``FAMILY_FIRST`` or ``FAMILY_FIRST_GIVEN_LAST`` ``name_order``. - Defaults to empty. + Each rule also fills ``ParsedName.patronymic``, under every + order. Defaults to empty. * - ``middle_as_family`` - ``bool`` - Folds ``middle`` into ``family`` instead of splitting them — diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md new file mode 100644 index 000000000..65ec13b6c --- /dev/null +++ b/docs/design/decisions/R9-patronymic-view.md @@ -0,0 +1,23 @@ +# R9 — the patronymic view + +## Standing + +- `ParsedName.patronymic` holds the words read as a patronymic, in whichever field the parse put them, which keep them; it is a view, not a role, so `family`, `middle`, `as_dict()`, `replace()`'s fields and the repr are unchanged, item 3 of decisions/vocabulary-model's 2026-10-28 #754 output-model entry — 2026-10-29 #754, sub-phase 4c. +- A patronymic particle and the words after it in its field all read `patronymic`, the particle by vocabulary and the rest by position, so the view is the tokens whose reading is `patronymic` — 2026-10-29 #754, sub-phase 4c. +- The East Slavic and Turkic rules write the reading wherever a policy turns them on, not only where their rotations fire: a middle word carrying the ending, and a marker with the name word before it — 2026-10-29 #754, sub-phase 4c. + +Elsewhere: why the view leaves `family` and `middle` as they are, and the fielded-corpora measurements behind it, decisions/vocabulary-model's 2026-10-28 #754 entry, item 3; the rotations the two ending rules are named for, decisions/O1 and decisions/O2; the `clan`/`patronymic` line in the particle kinds, decisions/vocabulary-model's 2026-10-19 #749 entry. + +Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's work order: 3b, 4c and 4f); whether the East Slavic endings should take the Ukrainian `-ivna`/`-ївна` and `-ovych`/`-ович` spellings, which would move O1's rotation too. + +## Log + +- 2026-10-29 #754, sub-phase 4c — IMPLEMENTED item 3 of decisions/vocabulary-model's 2026-10-28 #754 entry. What the implementation decided, beyond that entry: + 1. **Every word of a particle's patronymic reads `patronymic`.** The entry left open whether the particle reads `patronymic`, or only the name after it; the view's text is `bin Mohamad`, so a view over one reading kind needs both, and `ReadingKind.PATRONYMIC`'s docstring already gave "bin Mohamad" as the words it names. The particle's reading is the vocabulary's (`vocabulary`, its files `ar ms` for `bin`), each word after it `position`: what makes it a patronymic is where it stands, after the particle, so it names no file, and its own reading (a name word, a bound given name, a chained particle) is not kept. Weighed against: the particle alone reading `patronymic` and the view adding the words after it, which would put a run-walk in the view and make `Token.reading` say `name` for a word the view counts as the parent's name. + 2. **The run is the field, read at assemble.** The words after the particle in its field, a nickname skipped and a suffix ending it, measured over the final roles rather than group's pieces, since P1's fold and P6's attachment move words after group and a leading never-given particle stays a lone piece for the fold to find. So the run is what the family or middle holds after the particle: `ibn Ali Hasan` gives `ibn Ali Hasan` by default and `ibn Ali` under a family-first order, as the family does. Under `middle_as_family` the run follows the field the word stands as, so a folded middle's run stops at the family's own words. + 3. **A particle names a parent only with a word after it**, or where P6 attached it after a family comma or a family-first order to the family written before it, whose words are then the run (`Mohamad, Mahathir bin` gives `bin Mohamad`, written in the family's order since P6's particle carries the folded mark the family writes first). The first draft read every patronymic particle read as a particle, and the snapshot showed `Fritz Freiherr und von Bar` reading patronymic `Bar`: the German family name spelled as Hebrew `bar`, ending its field, which group's chain had left a particle. + 4. **The kind is read at classify from the reading index**, as R8's suffix kind is: `particles` compiles every particle kind alike, so classify writes `vocab:patronymic` beside `particle` for a word the shipped files hold as a `patronymic` particle (`_vocab.is_patronymic_particle`, a memo over `_reading_index.holds_kind`), whatever the reading's default, and assemble drops the tag from the published token, a published tag making a name unequal to its 2.x pickle. Every patronymic particle has that kind only (`bar`, `bat`, `bin`, `bint`, `binte`, `binti`, `ibn` and the five native-script forms, 2026-10-29), so the lookup asks for the kind, not the first particle row. A word only a caller's `Lexicon` lists is a plain particle, `ben` included, which `ar.toml` excludes as a patronymic. + 5. **The report's alternative moves with the reading.** A leading patronymic particle read as a given name names `patronymic as family` where it named `particle as family` (`bin Mohamad`), through `_vocab.particle_reading`, which both of assign's particle alternatives now ask, so `Token.reading` and `Ambiguity.alternative` agree, as R8's 4b made them for `Jr`. + 6. **The ending rules read a word, not only a rotation.** O1 and O2 fire on exactly three and four name words; the patronymic is written in more shapes than that (`Ivanov, Ivan Ivanovich`, `Ivan Ivanovich Ivanov`, which the rotation leaves alone), so the East Slavic arm reads a middle word carrying O1's ending, and the Turkic arm a marker in a name field with the name word before it in its comma part, wherever the rule is on. The middle is the field a patronymic stands in after a given name, and a family word carrying the ending is a surname as often as not (`Roman Abramovich`). The patterns moved from `_post_rules` to `_vocab` as `has_patronymic_ending` and `is_patronymic_marker`, which the rotations and assemble both ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION), the packs' hand copies pinned against them there. + 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 18 names, 6,588 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse): the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees; a patronymic particle adds one (`Mahathir bin Mohamad` 217 to 218), P6's attached shape nine (`Mohamad, Mahathir bin` 263 to 272, its family's tokens rebuilt), and the opt-in rules two (`Ivan Ivanovich Ivanov` under RU, 211 to 213) and eight (`Ali Ahmad Vali oglu` under TR_AZ, 243 to 251). diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index a5343238d..5ec7e6a6d 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -127,6 +127,7 @@ Entry conventions: - [R6 — the default rendering follows the written name](R6-default-rendering-follows-the-written-name.md) - [R7 — no initials in a script that writes none](R7-no-initials-in-a-script-that-writes-none.md) - [R8 — the generation and credentials views](R8-generation-and-credentials-views.md) +- [R9 — the patronymic view](R9-patronymic-view.md) ## Construction & configuration diagnostics (D) diff --git a/docs/design/decisions/vocabulary-model.md b/docs/design/decisions/vocabulary-model.md index fe8ea4c31..33f5f6730 100644 --- a/docs/design/decisions/vocabulary-model.md +++ b/docs/design/decisions/vocabulary-model.md @@ -9,7 +9,7 @@ The vocabulary was a set of base sets plus marker subsets (`particles` and `part - A language file holds the words the parser might meet in names written in that language, immigrant particles included, not the words of its etymology, and a word sits in the file of every language whose names it occurs in, a Latin transliteration in its source language's file; `mul.toml` holds the words shared on purpose instead (the generations `i` to `v` and `2`, `&`, and the European titles below) — 2026-10-15/16; 2026-10-18 #737; 2026-10-19 #749; 2026-10-19 #750, title kinds. - The default loads every language and keeps working for names of any language. Choosing a language is to narrow it in phase 3 (#753): a pack is to settle only the clashes where its language has a vocabulary reading, a strict pack to drop other languages' name-level readings too, and a pack's preferred reading to be able to outrank one a shape rule derives — 2026-10-15/16. - `e`, `y` and `i` stay in the default; choosing a language settles their one-case fork for that language's own reading, `may_be_initial` not holding on a chosen file's conjunction, which no shipped pack does yet — 2026-10-15/16; 2026-10-29 #753, phase 3b. -- A patronymic field is to come in 3.0, as a view that names its words without emptying `family`, and the particle kinds already draw the `clan`/`patronymic` line for it: a patronymic is followed by a parent's name, a clan prefix by a family name the lineage shares — 2026-10-15/16; 2026-10-19 #749; 2026-10-28 #754 (Derek). +- A patronymic field is to come in 3.0, as a view that names its words without emptying `family`, and the particle kinds already draw the `clan`/`patronymic` line for it: a patronymic is followed by a parent's name, a clan prefix by a family name the lineage shares — 2026-10-15/16; 2026-10-19 #749; 2026-10-28 #754 (Derek); landed as rules/R9, 2026-10-29 #754, sub-phase 4c. - A language pack is a choice of languages on `Locale.languages` selecting which readings compile, a tag choosing its own file and its ancestors', and a reading index, loaded on its first lookup, carries the languages to run time; strict waits for 3i, and third-party language files are deferred — 2026-10-27 #753 (Derek); 2026-10-29 #753, phase 3a (Derek); 2026-10-29 #753, phase 3b. - A choice is matched without regard to case, a tag whose file does not exist selects its ancestors' (`pt-PT` selects `pt`), `mul` and `und` are no language to choose, and a reading turned on also prefers, so `de`'s generation `i` drops `ca`'s and `pl`'s conjunction under a `de` choice; a choice is one call's: one chained through `base=` applies on top of the base's rather than unioning with it, and a choice drops the caller's own base entries it removes — 2026-10-29 #753, phase 3b; 2026-10-29 #753, phase 3b, chained choices (Derek). - A reading right in its own language and wrong only as a default, because another language written in the same script collides with it, is written `default = false` in its file, never excluded, so that after the packs an exclusion means wrong everywhere: cs `st` since phase 2h, and cs `ml`, hr, sk and sl `ml` and `st` and fr `père` since 3c, the rest in their packs' PRs — 2026-10-27 #753 (Derek); 2026-10-29 #753, phase 3c. @@ -320,3 +320,4 @@ Open: #913 (the title words phase 2j left, read as titles though borne as names) - **Open for 3f: a turned-on `ml` reads a lone comma part as the suffix, silently.** `ml` has no name reading, so under a choice it is an ungated suffix word: `Novak, ML` reads suffix `ML` with nothing reported, where the default reads given `ML`, and `Novak ml., Anna` family `Novak`, suffix `ml.`; the gated `st` reports `SUFFIX_OR_NAME`, and fr `Jean Père` reports `GIVEN_OR_FAMILY` (suffix `Père`). The initial pairs `Anna M. L. Novak` and `Anna ML Novak` do not move. Pinned in tests/v2/test_locales.py; whether `ml` takes a name reading or a gate is 3f's. So is the one-letter gap: es `h` and de `i` turned on stay inert written with a period (`Juan Pérez H.` keeps family `H.` under `es`, `Wilhelm Karl I.` family `I.` under `de`), a strict expected failure there. - Not touched: no `Lexicon` or `Parser` field, no code. The vocabulary README's example of an exclusion is now `duke`, wrong everywhere, where it was `père`. - **2026-10-29 #754, sub-phase 4b — `generation` AND `credentials` LAND AS VIEWS PARTITIONING `suffix`, AND A LISTED SUFFIX WORD'S KIND IS READ FROM THE READING INDEX; NO FIELD OF ANY PARSE MOVES.** Carries out item 4 of the 2026-10-28 #754 output-model entry and closes 4a's `Jr`/`Sr` seam: `_vocab.suffix_kind` asks the reading index for the kind of a word tagged `vocab:suffix-word`, so `Token.reading` and `Ambiguity.alternative` read `Jr` as a generation through the one predicate. What the implementation decided, the measurement and its recipe are in decisions/R8's 2026-10-29 entry, and the rule is rules/R8. +- **2026-10-29 #754, sub-phase 4c — `patronymic` LANDS AS A VIEW, READ FROM A PATRONYMIC PARTICLE'S RUN AND FROM THE RU AND TR_AZ RULES WHEREVER THEY ARE ON; NO FIELD OF ANY PARSE MOVES.** Carries out item 3 of the 2026-10-28 #754 output-model entry: the particle and the words after it in its field read `patronymic`, a particle's kind read from the reading index at classify as 4b reads a suffix word's, and `ReadingKind.PATRONYMIC`, which 4a added and no reading took (the 2026-10-29 sub-phase 4a entry, item 5), is now taken. What the implementation decided, the measurement and its recipe are in decisions/R9's 2026-10-29 entry, and the rule is rules/R9. diff --git a/docs/design/rules/R9-patronymic-view.md b/docs/design/rules/R9-patronymic-view.md new file mode 100644 index 000000000..53c26b3ed --- /dev/null +++ b/docs/design/rules/R9-patronymic-view.md @@ -0,0 +1,77 @@ +# R9 — the patronymic view + +**When:** a name holds a word saying whose child the bearer is, a patronymic particle the vocabulary lists before the parent's name, as in "Mahathir bin Mohamad", or a patronymic ending or marker an opt-in rule reads, as in "Ivan Ivanovich Ivanov" under the East Slavic rule. + +**Then:** the words read as a patronymic, in whichever field the parse put them, make the patronymic view, every field keeping them, and the view joins them with spaces in written order, the family's words in the order the family writes them. + +- **A patronymic particle**: a particle the shipped vocabulary holds as a patronymic, read as a particle, with the words after it in its field, the parent's name. +- **The East Slavic rule** (`PatronymicRule.EAST_SLAVIC`, which RU turns on): a middle word carrying O1's patronymic ending. +- **The Turkic rule** (`PatronymicRule.TURKIC`, which TR_AZ turns on): a standalone patronymic marker, O2's, with the name word before it in its comma part, the father's name. + +**Because:** a patronymic names the parent, which a caller addressing the bearer or matching records needs to tell from a family name, and the people filling in fielded records, like CLDR's samples, put it in the family or the given part. Naming it in a view of its own leaves every field where those sources put it. The view reads only a word or a rule that says patronymic, never a position, since nothing in a name's shape says which name word is a parent's. + +``` +"Mahathir bin Mohamad" → patronymic="bin Mohamad" +"Mahathir bin Mohamad" → family="bin Mohamad" +"Ahmad bin Abdul Rahman" → patronymic="bin Abdul Rahman" +"Abdul Rahman bin Ahmad" → patronymic="bin Ahmad" +"Ali bin Ahmad bin Hasan" → patronymic="bin Ahmad bin Hasan" +"Siti binti Abu Bakar" → patronymic="binti Abu Bakar" +"Moshe bar Yosef" → patronymic="bar Yosef" +"محمد بن سلمان" → patronymic="بن سلمان" +"Mahathir bin Mohamad Jr." → patronymic="bin Mohamad" +"bin Mohamad, Mahathir" → patronymic="bin Mohamad" +"Ivan Ivanovich Ivanov" [ru] → patronymic="Ivanovich" +"Ivan Ivanovich Ivanov" [ru] → middle="Ivanovich" +"Sidorov Ivan Petrovich" [ru] → patronymic="Petrovich" +"Ivanov, Ivan Ivanovich" [ru] → patronymic="Ivanovich" +"Aliyev, Heydar Alirza oglu" [tr_az] → patronymic="Alirza oglu" +"Ali Ahmad Vali oglu" [tr_az] → patronymic="Vali oglu" +"Ivan Ivanovich Ivanov" → patronymic="" · boundary +"Anwar Ibrahim" → patronymic="" +``` + +**Except:** + +- **A patronymic particle read as a name word names no parent**, so where the parse reads it as a given name it is no patronymic. + ``` + "Mahathir, bin Mohamad" → patronymic="" + "bin Mohamad" → patronymic="" + ``` +- **A patronymic particle ending its field names no parent**, so a family word spelled like one stays a name word. + ``` + "Fritz Freiherr und von Bar" → patronymic="" + ``` +- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, the view writing them in the family's order. + ``` + "Mohamad, Mahathir bin" → patronymic="bin Mohamad" + ``` +- **A nickname inside the parent's name** is skipped, not a word of the view. + ``` + "Ahmad bin “Bob” Ali" → patronymic="bin Ali" + ``` +- **A Turkic marker with no name word before it in its comma part** names no father and is no patronymic. + ``` + "oglu Ali" [tr_az] → patronymic="" + "Aliyev, oglu" [tr_az] → patronymic="" + ``` + +**Accepted:** + +- A patronymic written with no particle and no rule to read it is not read, since a position says nothing about a parent: Wikidata's Malay names with a patronymic and no family name agree 4 of 9, those written without `bin` read as given and family. +- The particle's run is every word after it in its field, so where the parse joined more than the parent's name the view holds it too, as the family does: a never-given particle opening the name takes the rest of it by default (P1). + ``` + "ibn Ali Hasan" → patronymic="ibn Ali Hasan" + ``` +- The East Slavic arm reads the middle only, the field the parse puts a patronymic in after a given name: one the parse put in the family (a two-word `Ivan Petrovich`) or in the given name (FAMILY_FIRST_GIVEN_LAST's last word) is not read, and under a declared FAMILY_FIRST a surname the order puts in the middle is, `Иван Петрович Абрамович` reading patronymic `Абрамович`, the declaration O1 honors. +- The East Slavic endings are O1's, so a Ukrainian `-ївна` (`Оксана Сергіївна Баюл`) is not read. +- The Turkic father's name may stand in another field than the marker, as O2 leaves a three-word name. + ``` + "Ali Ahmad oglu" [tr_az] → patronymic="Ahmad oglu" + ``` +- A patronymic particle is the shipped vocabulary's, read whatever the reading's default, as R8 reads a suffix word's kind, so a word only a caller's `Lexicon` lists as a particle (`ben`, which the vocabulary excludes as a patronymic) is a plain particle. +- A word the particle's run or the Turkic marker takes reads `patronymic` by position, its own reading (a name word, a bound given name, a particle) not kept in `Token.reading`. + +--- +**Signal:** neighbor + shape: a listed patronymic particle before the parent's name or an opt-in marker after it, and an opt-in patronymic ending on a middle word +**history:** decisions/R9 · **implemented:** nameparser/_types.py, nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_vocab.py diff --git a/docs/design/rules/README.md b/docs/design/rules/README.md index 2647f0ad0..7d1ae43e8 100644 --- a/docs/design/rules/README.md +++ b/docs/design/rules/README.md @@ -223,6 +223,7 @@ Terms the rules use without defining them each time, each with the rule that def - [R6 — the default rendering follows the written name](R6-default-rendering-follows-the-written-name.md) - [R7 — no initials in a script that writes none](R7-no-initials-in-a-script-that-writes-none.md) - [R8 — the generation and credentials views](R8-generation-and-credentials-views.md) +- [R9 — the patronymic view](R9-patronymic-view.md) ## [Construction & configuration diagnostics (D)](D-construction-and-configuration-diagnostics.md) diff --git a/docs/design/vocabulary-model-rfc.md b/docs/design/vocabulary-model-rfc.md index 91d464d03..c4a9cf935 100644 --- a/docs/design/vocabulary-model-rfc.md +++ b/docs/design/vocabulary-model-rfc.md @@ -272,7 +272,7 @@ Languages with one to three candidates (`tr` `bay`, `it` `sig`, `lt` `jaun`, `ms ## Phase 4: order, shape and the output model -Status: **the output model is agreed** (2026-10-28, #754), and sub-phases 4a, 4b and 4e have landed, `Reading.languages` with 4a (2026-10-29: decisions/vocabulary-model's 2026-10-29 #754, sub-phase 4a and 4a's follow-up entries, decisions/R8's for 4b and decisions/R6's for 4e). The record is decisions/vocabulary-model's 2026-10-28 #754 entry; the output-model and work-order subsections below are the design its items describe. The first two subsections, order and shape and the rules a pack can choose, are as written before and are amended only where the design reaches them. Phase 4 is 3.0 work. The core API (`parse`, `Parser`, `Lexicon`, `Policy`, `Locale`, `ParsedName`) is frozen for 3.0 except for additions (decisions/3-0-reevaluations, 2026-10-28 #925, THE CORE API), so every change below says whether it is an addition. +Status: **the output model is agreed** (2026-10-28, #754), and sub-phases 4a, 4b, 4c and 4e have landed, `Reading.languages` with 4a (2026-10-29: decisions/vocabulary-model's 2026-10-29 #754, sub-phase 4a and 4a's follow-up entries, decisions/R8's for 4b, decisions/R9's for 4c and decisions/R6's for 4e). The record is decisions/vocabulary-model's 2026-10-28 #754 entry; the output-model and work-order subsections below are the design its items describe. The first two subsections, order and shape and the rules a pack can choose, are as written before and are amended only where the design reaches them. Phase 4 is 3.0 work. The core API (`parse`, `Parser`, `Lexicon`, `Policy`, `Locale`, `ParsedName`) is frozen for 3.0 except for additions (decisions/3-0-reevaluations, 2026-10-28 #925, THE CORE API), so every change below says whether it is an addition. ### Order is not shape @@ -387,14 +387,14 @@ Each sub-phase is one PR, measured on its own base as phase 2's and phase 3's ar |---|---|---| | 4a | `Token.reading` and `ReadingKind.PATRONYMIC`, kind and source read at assemble from each token's final role and the tags the deciding stages left; `Reading.languages` once 3a's index exists. Landed (decisions/vocabulary-model's 2026-10-29 #754, sub-phase 4a and 4a's follow-up entries) | — (neutral); 3a for `languages` | | 4b | `generation` and `credentials` (#326); a listed suffix word's kind read from 3a's index, which 4a could not split (`Jr` and `Esq.` both read `post-nominal` there). Landed (decisions/R8's 2026-10-29 entry) | 4a (neutral); 3a for the kind | -| 4c | `patronymic`: the vocabulary's patronymic particles, and the RU and TR_AZ rules writing the reading | 4a (neutral) | +| 4c | `patronymic`: the vocabulary's patronymic particles, and the RU and TR_AZ rules writing the reading; a particle's patronymic kind read from 3a's index, as 4b reads a suffix word's. Landed (decisions/R9's 2026-10-29 entry) | 4a (neutral); 3a for the kind | | 4d | `surname2`: O6's stable tag | — (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 | 3b | | 4g | the compound given name, a rule kind (#704) | 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; 4c and 4d need nothing of phase 3, and 4b needs 3a's index only for a listed suffix word's kind; 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. +**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. **Left open.** With no sub-phase yet: phase 1's four reserved names said to be phase 4's (`separators` with phase 4's glued words, and `bare_only`, `leading_only` and `trailing_only`, which this RFC's phase 2 section found not needed), whose build messages still name this phase. diff --git a/docs/locales.rst b/docs/locales.rst index 15c2e2ed8..8d0c86e60 100644 --- a/docs/locales.rst +++ b/docs/locales.rst @@ -273,13 +273,16 @@ Shipped packs family/given/patronymic shape (three name words, the last ending like a patronymic — Cyrillic and transliterated ``-ovich``/``-ovna``-style endings — and the middle not) and - assigns it accordingly. + assigns it accordingly. A middle name with the ending fills + ``patronymic`` too (``Ivan Ivanovich Ivanov`` → middle and + patronymic ``Ivanovich``). * - ``tr_az`` - Turkic patronymic markers — detects a name of four words ending in a standalone marker token (``oglu``, ``qizi``, ``uulu``, and their Latin- and Cyrillic-script variants) and reads its first word as the family name (``Aliyev Ilham Heydar oglu`` → family - ``Aliyev``). + ``Aliyev``). A marker and the name before it fill + ``patronymic`` (``Heydar oglu`` there). * - ``zh`` - Chinese surname segmentation — splits an unspaced Han name into surname and given name (``毛泽东`` → family ``毛``, given @@ -326,7 +329,9 @@ order already reads ``"Сидоров Иван Петрович"`` as family `` so folding the pack onto a base built with it adds nothing. Under ``FAMILY_FIRST_GIVEN_LAST`` the declared order wins, and the last word — the patronymic, or a separate ``oglu``/``qizi`` marker — reads -as the given name. +as the given name. ``patronymic`` does not stand down: it reads a middle +word with the ending, or a marker and the word before it, under every +order, wherever the declared order put them. .. _segmenter-contract: diff --git a/docs/release_log.rst b/docs/release_log.rst index 80e8a1649..b5c3f326f 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -8,6 +8,7 @@ Release Log - **Add Locale.languages, a choice of languages for the default vocabulary.** ``parser_for(Locale(code="pt", lexicon=Lexicon.empty(), languages=frozenset({"pt"}))).parse("ANA SILVA E SOUZA")`` gives family ``SILVA E SOUZA``, where the default reads family ``SOUZA``, the ``E`` an initial: a chosen language's reading of a word it has a vocabulary reading of wins, and the other languages' vocabulary readings of that word leave, while its readings as a name and the readings shared by every language stay, so under ``pt`` ``John Smith DO`` reads family ``DO``. A generation word another language collides with is read only under its own language: ``Jiří Dienstbier ml.`` gives suffix ``ml.`` under ``cs``, and the Czech, Slovak, Slovenian and Croatian ``ml.`` and ``st.`` and the French ``père`` read this way. A pack choosing no language parses as before. See :doc:`locales`. :issue:`753` - **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``, ``ibn``, ``bint``, ``bar``, ``bat`` and their Arabic and Hebrew forms) or a rule a locale turns on, so ``Anwar Ibrahim`` has none. ``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 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/docs/usage.rst b/docs/usage.rst index 5851b851a..5aac00096 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -735,6 +735,19 @@ unchanged: >>> suffixed.render("{given} {family} {generation}") 'John Smith Jr.' +``patronymic`` names the words that say whose child the bearer is, a +particle such as ``bin`` with the parent's name after it, in whatever +field the parse put them, which keeps them. It reads no position, so a +name written without the particle has none: + +.. doctest:: + + >>> nasab = parse("Mahathir bin Mohamad") + >>> nasab.patronymic, nasab.family + ('bin Mohamad', 'bin Mohamad') + >>> parse("Anwar Ibrahim").patronymic + '' + Dicts and strings ------------------ diff --git a/nameparser/_pipeline/AGENTS.md b/nameparser/_pipeline/AGENTS.md index 5900df5a6..401d66f2b 100644 --- a/nameparser/_pipeline/AGENTS.md +++ b/nameparser/_pipeline/AGENTS.md @@ -34,7 +34,7 @@ The stage-ownership map in `tests/v2/pipeline/test_state.py` must list `ambiguit ### Name the reading not taken -**Every emitter also names the reading it did not take** (`PendingAmbiguity.alternative`, built with `_vocab.suffix_reading`/`name_reading` or `PARTICLE_READING`/`TITLE_READING`, or a `PendingReading` written out where none fits, classify's connective and O5's lone word; 2.7, #752 phase 2m): a non-name reading names its field by what it is, and a name reading's field is the positional read's with every other word kept, or the piece in front where that piece would take the word in (`_assign._name_alt`: a particle chain, a bound given name). A stage that runs before positions exist defers the field (`_state.Deferred`: classify's connective, group's chain), assign names it, and one assign cannot place surfaces as no alternative rather than a guess (a connective letter opening its part, which has no word before it to join). +**Every emitter also names the reading it did not take** (`PendingAmbiguity.alternative`, built with `_vocab.suffix_reading`/`name_reading` or `particle_reading` (`PARTICLE_READING`, or `PATRONYMIC_READING` for a patronymic particle, rules/R9)/`TITLE_READING`, or a `PendingReading` written out where none fits, classify's connective and O5's lone word; 2.7, #752 phase 2m): a non-name reading names its field by what it is, and a name reading's field is the positional read's with every other word kept, or the piece in front where that piece would take the word in (`_assign._name_alt`: a particle chain, a bound given name). A stage that runs before positions exist defers the field (`_state.Deferred`: classify's connective, group's chain), assign names it, and one assign cannot place surfaces as no alternative rather than a guess (a connective letter opening its part, which has no word before it to join). The field is `compare=False`, so a site that REBUILDS an `Ambiguity` (`_render.capitalized` does, mapping its tokens) must pass it on, and is tested by comparing the alternatives themselves: equal names say nothing about it, and #902's review found `capitalized()` dropping every one. A site that rebuilds a `Token` must pass on its `reading` the same way, for the same reason (below). diff --git a/nameparser/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py index 09f34cec1..0d11e30c4 100644 --- a/nameparser/_pipeline/_assemble.py +++ b/nameparser/_pipeline/_assemble.py @@ -13,7 +13,8 @@ three layers away. Reads: Policy.middle_as_family, folding a report's other field as the policy folds a middle; Policy.name_order, for ParsedName.order where -no positional read happened; and Lexicon.surnames, suffix_acronyms and +no positional read happened; Policy.patronymic_rules, for the words +an ending rule reads as a patronymic (rules/R9); and Lexicon.surnames, suffix_acronyms and suffix_acronyms_ambiguous, for the source of a token's reading where no tag gives it. Not a Lexicon: the shipped reading index (_reading_index.languages_of), for Reading.languages, which names the @@ -39,13 +40,16 @@ from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, CREDENTIAL_WORD_TAG, FIELD_READING, GENERATION_WORD_TAG, - NAME_ROLES, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, + NAME_ROLES, PATRONYMIC_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, + SPLIT_MEMBER_TAG, TITLE_AMBIGUOUS_TAG, Beside, Deferred, ParseState, PendingAmbiguity, - Structure, WorkToken, + Structure, WorkToken, comma_bucket, ) from nameparser._pipeline._vocab import ( - _PERIOD_ABBREV, name_source, suffix_kind, + _PERIOD_ABBREV, has_patronymic_ending, is_patronymic_marker, + name_source, suffix_kind, ) +from nameparser._policy import PatronymicRule from nameparser._reading_index import CATEGORY, languages_of from nameparser._types import ( FOLDED_TAG, GIVEN_FIRST, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, @@ -70,14 +74,16 @@ def _kinds_of(*categories: str) -> frozenset[str]: #: suffix rows whatever their finer kind, since a word has one suffix #: kind (the build's check 4), which `_vocab.suffix_kind` has already #: read the reading's kind by (4b). -#: A name reading's rows depend on its field (`_NAME_KINDS`). An -#: initial and a patronymic are read by shape or rule, never from a -#: file. +#: A name reading's rows depend on its field (`_NAME_KINDS`). A +#: patronymic's are the patronymic particle's (rules/R9); the words its +#: run takes, and a patronymic an ending rule reads, are read by +#: position or shape, as an initial is, never from a file. _SUFFIX_KINDS = _kinds_of("suffix") _KINDS: MappingProxyType[ReadingKind, frozenset[str]] = MappingProxyType({ ReadingKind.TITLE: _kinds_of("title"), ReadingKind.PARTICLE: _kinds_of("particle"), ReadingKind.CONJUNCTION: _kinds_of("conjunction"), + ReadingKind.PATRONYMIC: frozenset({"patronymic"}), ReadingKind.CREDENTIAL: _SUFFIX_KINDS, ReadingKind.GENERATION: _SUFFIX_KINDS, ReadingKind.AGNOMEN: _SUFFIX_KINDS, @@ -199,7 +205,8 @@ def _split_partner(tokens: tuple[WorkToken, ...], dropped: set[int], #: classify's kind tags for a listed suffix word (rules/R8), which no #: published token carries: `Token.reading` names the kind. -_KIND_TAGS = frozenset({GENERATION_WORD_TAG, CREDENTIAL_WORD_TAG}) +_KIND_TAGS = frozenset({GENERATION_WORD_TAG, CREDENTIAL_WORD_TAG, + PATRONYMIC_TAG}) #: The tags that say a list or a shape made a word a suffix: without #: one, a suffix word stands there by position, unless the vocabulary @@ -270,6 +277,54 @@ def _opens_its_segment(state: ParseState, i: int) -> bool: and state.tokens[j].role is not Role.TITLE), None) == i +# rules/R9: "a standalone patronymic marker, O2's, with the name word +# before it in its comma part" -- the Turkic arm (decisions/R9, #754) +def _turkic_patronymics(state: ParseState, + dropped: set[int]) -> tuple[set[int], set[int]]: + """The markers rules/R9's Turkic arm reads as a patronymic, and the + father's name before each: a marker standing in a name field, with + the word before it, a nickname aside, standing in one too and in + the marker's own comma part. Asked only where the policy turns the + rule on.""" + markers: set[int] = set() + fathers: set[int] = set() + tokens = state.tokens + commas = state.comma_offsets + for i, t in enumerate(tokens): + if (i in dropped or t.role not in NAME_ROLES + or not is_patronymic_marker(t.text)): + continue + j = i - 1 + while j >= 0 and (j in dropped or tokens[j].role is Role.NICKNAME): + j -= 1 + if (j >= 0 and tokens[j].role in NAME_ROLES + and comma_bucket(tokens[j].span.start, commas) + == comma_bucket(t.span.start, commas)): + markers.add(i) + fathers.add(j) + return markers, fathers + + +def _opens_a_patronymic(state: ParseState, dropped: set[int], i: int, + field: Role) -> bool: + """Whether the patronymic particle at `i`, read as a particle, names + a parent (rules/R9): a word after it in its field, a nickname + aside, or the family written before it that P6 attached it to + ('Mohamad, Mahathir bin'). One ending its field names nobody, a + family word spelled as one ('Bar' in 'Fritz von Bar'). Asked only + of a patronymic particle, so other names pay nothing.""" + tokens = state.tokens + tags = tokens[i].tags + if field is Role.FAMILY and FOLDED_TAG in tags: + return True + j = i + 1 + while j < len(tokens) and (j in dropped + or tokens[j].role is Role.NICKNAME): + j += 1 + return (j < len(tokens) and tokens[j].role is tokens[i].role + and (FOLDED_TAG in tokens[j].tags) == (FOLDED_TAG in tags)) + + def _listed_suffix(text: str, lexicon: Lexicon) -> bool: """Whether the suffix vocabulary lists, its periods removed, an acronym no tag marks: a bracketed member written with an interior @@ -296,6 +351,17 @@ def assemble(state: ParseState) -> ParsedName: # credential split across a space, whose second word takes it before: Reading | None = None prev_split = False + # the field of the patronymic a patronymic particle opened, whose + # run takes the words after it there; and the ending rules' words, + # where a policy turns one on + run_field: Role | None = None + # a patronymic particle rules/P6 attached to the family written + # before it, whose run is that family's own words + attached = False + rules = state.policy.patronymic_rules + slavic = PatronymicRule.EAST_SLAVIC in rules + markers, fathers = (_turkic_patronymics(state, dropped) + if PatronymicRule.TURKIC in rules else ((), ())) for i, t in enumerate(state.tokens): if i in dropped: continue @@ -322,6 +388,7 @@ def assemble(state: ParseState) -> ParsedName: # frame (decisions/vocabulary-model, 2026-10-29). reading: Reading | None = None if role is Role.SUFFIX: + run_field = None kind, source = suffix_kind(t.text, tags) if (source is ReadingSource.VOCABULARY and tags.isdisjoint(_SUFFIX_EVIDENCE)): @@ -421,6 +488,28 @@ def assemble(state: ParseState) -> ParsedName: # folded into the family by the middle it stands as field = (Role.MIDDLE if role is Role.FAMILY and folds_middle and FOLDED_TAG in tags else role) + # rules/R9: "a particle the shipped vocabulary holds as a + # patronymic", read as a particle, opens a patronymic that + # takes the words after it in its field; an ending rule a + # policy turns on reads its own words (decisions/R9, #754) + if (kind is ReadingKind.PARTICLE and PATRONYMIC_TAG in tags + and _opens_a_patronymic(state, dropped, i, field)): + kind = ReadingKind.PATRONYMIC + run_field = field + attached = attached or (field is Role.FAMILY + and FOLDED_TAG in tags) + elif field is run_field: + kind, source = ReadingKind.PATRONYMIC, ReadingSource.POSITION + else: + if role is not Role.NICKNAME: + run_field = None + if i in fathers: + kind, source = (ReadingKind.PATRONYMIC, + ReadingSource.POSITION) + elif kind is ReadingKind.NAME and ( + i in markers or slavic and field is Role.MIDDLE + and has_patronymic_ending(t.text)): + kind, source = ReadingKind.PATRONYMIC, ReadingSource.SHAPE langs = _languages(t.text, kind, field) if source in _LISTED else () if (not langs and source in _LISTED and (langs := _part_languages(t.text, kind, field)) @@ -435,6 +524,17 @@ def assemble(state: ParseState) -> ParsedName: # name unequal to its own 2.x pickle (Token compares tags) tags = tags - _KIND_TAGS final[i] = Token(t.text, t.span, role, tags, reading) + if attached: + # rules/R9: "A trailing patronymic particle P6 attaches to the + # family written before it takes that family's words as the + # parent's name" (decisions/R9, #754) + for j, tok in final.items(): + if (tok.role is Role.FAMILY and FOLDED_TAG not in tok.tags + and tok.reading is not None + and tok.reading.kind is not ReadingKind.PATRONYMIC): + final[j] = Token(tok.text, tok.span, tok.role, tok.tags, + _READINGS[ReadingKind.PATRONYMIC, Role.FAMILY, + ReadingSource.POSITION]) # No alphanumeric character among the SURVIVING tokens means no # name: a bare '.' or '- -' is not a person. v1 kept such input # (parse('.') -> first '.'); 2.0 empties it so bool() stays an diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index dbcd65356..323a1c50c 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -94,7 +94,7 @@ from nameparser._lexicon import Lexicon, _run_addresses_by_given from nameparser._pipeline._vocab import ( _ROMAN, effective_script, in_a_title_phrase, is_initial_shaped, - PARTICLE_READING, TITLE_READING, is_listed_numeral, + TITLE_READING, is_listed_numeral, particle_reading, is_suffix_lenient, name_reading, suffix_reading, numeral_leans_to_the_name, numeral_settled_by_case, reads_as_a_reported_numeral, reads_as_generation, resolve_script_set, @@ -749,7 +749,7 @@ def as_a_name(i: int) -> PendingReading: AmbiguityKind.PARTICLE_OR_GIVEN, f"leading {tokens[head[0]].text!r} may be a family-name " f"particle; read as ", tuple(head), field_tail="", - alternative=PARTICLE_READING)) + alternative=particle_reading(tokens[head[0]].tags))) return order @@ -870,7 +870,7 @@ def is_title(q: int) -> bool: f"{text!r} written without periods is both a post-nominal and a " f"family-name particle; between credentials after a family " f"comma it reads as a post-nominal", - tuple(run), alternative=PARTICLE_READING)) + tuple(run), alternative=particle_reading(tokens[run[0]].tags))) return True diff --git a/nameparser/_pipeline/_classify.py b/nameparser/_pipeline/_classify.py index 972efd905..793539b95 100644 --- a/nameparser/_pipeline/_classify.py +++ b/nameparser/_pipeline/_classify.py @@ -33,8 +33,9 @@ "vocab:after-given-title", "vocab:title-ambiguous", "vocab:suffix", "vocab:suffix-word", "vocab:suffix-ambiguous", "vocab:suffix-word-ambiguous", "vocab:agnomen", -"vocab:generation-word", "vocab:credential-word" (read by the later -stages and dropped by assemble, Token.reading carrying the kind out), +"vocab:generation-word", "vocab:credential-word" and +"vocab:patronymic" (read by the later stages and dropped by assemble, +Token.reading carrying the kind out), "vocab:particle-ambiguous", "vocab:bound-given", "vocab:given-abbreviation", "vocab:maiden-marker", "vocab:maiden-marker-cont"; in a namespace of its own, @@ -71,7 +72,7 @@ from nameparser._policy import CapsSuffixes from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, - GIVEN_ABBREVIATION_TAG, SHAPE_ACRONYM_TAG, + GIVEN_ABBREVIATION_TAG, PATRONYMIC_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, TITLE_AMBIGUOUS_TAG, ParseState, Deferred, PendingAmbiguity, PendingReading, WorkToken, copy_with, @@ -80,7 +81,8 @@ from nameparser._pipeline._vocab import ( BUILTIN_GLUED_DELIMITERS, SPLIT_CHUNK, SPLIT_PAIR_OPENS, _INITIAL, caps_shape_candidate, composed_len, glued_cores, glued_stem, is_cased_paired_initials, - is_initial, is_one_case, listed_word_kind_tag, period_joined_vocab, + is_initial, is_one_case, is_patronymic_particle, listed_word_kind_tag, + period_joined_vocab, split_pair, suffix_as_written, tag_marker_runs, title_readings, ) @@ -174,6 +176,11 @@ def _tags_for(token: WorkToken, n: str, state: ParseState, tags.add("particle") if n in lex.particles_ambiguous: tags.add("vocab:particle-ambiguous") + # rules/R9: "a particle the shipped vocabulary holds as a + # patronymic" -- its kind, read here as a suffix word's is + # (decisions/R9, #754) + if is_patronymic_particle(n): + tags.add(PATRONYMIC_TAG) # rules/P8: "a single-letter connective reads as an initial # where the writing says so: written as a bare Latin capital in a # name that is not written wholly in one case, or — in a name diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index 0cb744e0d..850d55171 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -48,27 +48,14 @@ WorkToken, _NEVER_FLIPPED, comma_bucket, copy_with, ) from nameparser._pipeline._vocab import ( - delimiter_cores, suffix_reading, unit_ends, + delimiter_cores, has_patronymic_ending, is_patronymic_marker, + suffix_reading, unit_ends, ) from nameparser._policy import PatronymicRule from nameparser._types import ( FOLDED_TAG, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, AmbiguityKind, Role, ) -# Ported verbatim from v1 (nameparser/config/regexes.py) -- layering -# forbids the config import; keep in sync by hand. -_EAST_SLAVIC = re.compile( - r"(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$", - re.I) -_EAST_SLAVIC_CYR = re.compile( - r"(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$", - re.I) -_TURKIC = re.compile( - r"^(oglu|oğlu|ogly|ogli|o['’ʻ]g['’ʻ]li" - r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$", re.I) -_TURKIC_CYR = re.compile( - r"^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$", re.I) - #: The roles that are transparent to a run of post-nominals (R1's #: entry pass below). These three roles render into fields other than @@ -636,9 +623,7 @@ def post_rules(state: ParseState) -> ParseState: len(givens) == 1 and len(middles) == 1 and len(families) == 1: tail = tokens[families[0]].text mid = tokens[middles[0]].text - if (_EAST_SLAVIC.search(tail) or _EAST_SLAVIC_CYR.search(tail)) \ - and not (_EAST_SLAVIC.search(mid) - or _EAST_SLAVIC_CYR.search(mid)): + if has_patronymic_ending(tail) and not has_patronymic_ending(mid): g, m, f = givens[0], middles[0], families[0] _retag(tokens, m, Role.GIVEN) _retag(tokens, f, Role.MIDDLE) @@ -650,7 +635,7 @@ def post_rules(state: ParseState) -> ParseState: if rotations_apply and PatronymicRule.TURKIC in rules and \ len(givens) == 1 and len(middles) == 2 and len(families) == 1: tail = tokens[families[0]].text - if _TURKIC.match(tail) or _TURKIC_CYR.match(tail): + if is_patronymic_marker(tail): g, m1, m2, f = givens[0], middles[0], middles[1], families[0] _retag(tokens, m1, Role.GIVEN) _retag(tokens, m2, Role.MIDDLE) diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index 7f407b49e..6b9541f16 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -109,6 +109,13 @@ class WorkToken: GENERATION_WORD_TAG = "vocab:generation-word" CREDENTIAL_WORD_TAG = "vocab:credential-word" +#: Beside "particle" on a word the shipped reading index holds as a +#: patronymic particle (`bin`, `ibn`, `bat`), which the compiled sets +#: do not record (rules/R9, 3.0's 4c): written by classify, read by +#: assemble for `Token.reading` and by assign for the reading a report +#: names, and dropped from the published token like the kind tags above. +PATRONYMIC_TAG = "vocab:patronymic" + #: On a word of `Lexicon.titles_ambiguous` whichever way classify read #: it (rules/H9): read as a title it carries "vocab:title" too, read #: out of the title it carries this alone, which is how assign finds diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index 880d83aa3..42c298119 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -60,12 +60,12 @@ ) from nameparser._policy import (CapsSuffixes, Policy, Script, _JA_SCRIPTS, _NO_INITIALS, _SCRIPT_RANGES, _script_matcher) -from nameparser._reading_index import suffix_kind_of +from nameparser._reading_index import holds_kind, suffix_kind_of from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, COMMA_CHARS, CREDENTIAL_WORD_TAG, Deferred, GENERATION_WORD_TAG, - GIVEN_ABBREVIATION_TAG, SHAPE_ACRONYM_TAG, TITLE_AMBIGUOUS_TAG, - PendingReading, ReadingKind, ReadingSource, Role, WorkToken, + GIVEN_ABBREVIATION_TAG, PATRONYMIC_TAG, SHAPE_ACRONYM_TAG, + TITLE_AMBIGUOUS_TAG, PendingReading, ReadingKind, ReadingSource, Role, WorkToken, comma_bucket, ) @@ -275,6 +275,36 @@ def is_initial_shaped(text: str) -> bool: # v1 regexes.py "roman_numeral", pinned by tests/v2/test_regex_sync.py. _ROMAN = re.compile(r'^(X|IX|IV|V?I{0,3})$', re.I) + +# Ported verbatim from v1 (nameparser/config/regexes.py), pinned by +# tests/v2/test_regex_sync.py: the East Slavic patronymic endings and +# the standalone Turkic patronymic markers, which rules/O1's and O2's +# rotations and rules/R9's reading ask through the two predicates below. +_EAST_SLAVIC = re.compile( + r"(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$", + re.I) +_EAST_SLAVIC_CYR = re.compile( + r"(ович|овна|евич|евна|ична|ильич|кузьмич|лукич|фомич|фокич)$", + re.I) +_TURKIC = re.compile( + r"^(oglu|oğlu|ogly|ogli|o['’ʻ]g['’ʻ]li" + r"|qizi|qızı|kizi|kyzy|gyzy|uly|uulu)$", re.I) +_TURKIC_CYR = re.compile( + r"^(оглу|оглы|оғлу|ўғли|угли|кызы|гызы|қызы|қизи|улы|ұлы|уулу)$", re.I) + + +def has_patronymic_ending(text: str) -> bool: + """Whether `text` ends in an East Slavic patronymic ending, Latin or + Cyrillic ("Ivanovich", "Петрович"): rules/O1's test and, under + `PatronymicRule.EAST_SLAVIC`, rules/R9's.""" + return bool(_EAST_SLAVIC.search(text) or _EAST_SLAVIC_CYR.search(text)) + + +def is_patronymic_marker(text: str) -> bool: + """Whether `text` is a standalone Turkic patronymic marker ("oglu", + "кызы"): rules/O2's test and, under `PatronymicRule.TURKIC`, + rules/R9's.""" + return bool(_TURKIC.match(text) or _TURKIC_CYR.match(text)) #: The same pattern, shared rather than copied: `_comma` filters on #: it before asking `reads_as_generation` (rules/C12). ROMAN = _ROMAN @@ -2220,6 +2250,19 @@ def listed_word_kind_tag(n: str) -> str | None: return _LISTED_WORD_KIND_TAGS.get(suffix_kind_of(n) or "") +# rules/R9: "a particle the shipped vocabulary holds as a patronymic" +# -- read at classify, as a listed suffix word's kind is (decisions/R9, +# #754). A memo for the reason `listed_word_kind_tag` is one. +@functools.lru_cache(maxsize=4096) +def is_patronymic_particle(n: str) -> bool: + """Whether a file holds the normalized word `n` as a patronymic + particle (`bin`, `ibn`, `bat`), on by default or not: the kind the + compiled `particles` set does not record, read from the shipped + reading index (3.0's 4c), so a word only a caller's `Lexicon` + lists is a plain particle.""" + return holds_kind(n, "patronymic") + + def name_reading(token: WorkToken, role: Role | Deferred) -> PendingReading: """The name reading of a word a decision read as something else, landing in `role`: an initial where the word is one letter or @@ -2253,6 +2296,19 @@ def name_source(tags: Set[str]) -> ReadingSource: #: words after it, the last group reading as the family (rules/P2). PARTICLE_READING = PendingReading(ReadingKind.PARTICLE, ReadingSource.VOCABULARY, Role.FAMILY) +#: The same for a patronymic particle (rules/R9): its particle reading +#: is a patronymic, joining the parent's name after it. +PATRONYMIC_READING = PendingReading(ReadingKind.PATRONYMIC, + ReadingSource.VOCABULARY, Role.FAMILY) + + +def particle_reading(tags: frozenset[str]) -> PendingReading: + """The particle reading of a word read as something else, by the + kind classify tagged it with: a patronymic particle's is + `PATRONYMIC_READING`, any other's `PARTICLE_READING`.""" + return PATRONYMIC_READING if PATRONYMIC_TAG in tags else PARTICLE_READING + + #: The title reading of a word read as a name. TITLE_READING = PendingReading(ReadingKind.TITLE, ReadingSource.VOCABULARY, Role.TITLE) diff --git a/nameparser/_policy.py b/nameparser/_policy.py index c6fc2128c..504e3ca94 100644 --- a/nameparser/_policy.py +++ b/nameparser/_policy.py @@ -26,11 +26,12 @@ class PatronymicRule(StrEnum): #: East Slavic formal order: "Sidorov Ivan Petrovich" #: (family, given, patronymic) is detected by the patronymic - #: ending and reordered. Enabled by locales.RU. + #: ending and reordered, and a middle word with the ending fills + #: ParsedName.patronymic. Enabled by locales.RU. EAST_SLAVIC = "east-slavic" #: Turkic patronymic markers: a standalone "oglu"/"qizi"/"kyzy" - #: (etc.) binds to the preceding name as a patronymic. Enabled by - #: locales.TR_AZ. + #: (etc.) binds to the preceding name as a patronymic, the two + #: filling ParsedName.patronymic. Enabled by locales.TR_AZ. TURKIC = "turkic" diff --git a/nameparser/_reading_index.py b/nameparser/_reading_index.py index 084ceb82b..ba9227c41 100644 --- a/nameparser/_reading_index.py +++ b/nameparser/_reading_index.py @@ -379,6 +379,15 @@ def suffix_kind_of(text: str) -> str | None: return None +def holds_kind(text: str, kind: str) -> bool: + """Whether a file holds `text` in a reading of `kind` (a key of + :data:`CATEGORY`), on by default or not. `text` is normalized as + :func:`readings_of` takes it. What the parse reads a listed + particle's patronymic kind by (`_vocab.is_patronymic_particle`, + 3.0's 4c).""" + return any(k == kind for _tag, k, _pairs in _by_text().get(text, ())) + + class CompileError(ValueError): """The readings cannot compile: a fact about the data, which the build reports as a vocabulary error (exit 2) where any other diff --git a/nameparser/_render.py b/nameparser/_render.py index 0b7910781..5fbd9d7e0 100644 --- a/nameparser/_render.py +++ b/nameparser/_render.py @@ -37,7 +37,7 @@ #: str.format keys render() accepts: the seven role fields in canonical #: order (derived from Role -- never restated) plus the derived views. _DERIVED_VIEWS = ("family_base", "family_particles", "surnames", "given_names", - "generation", "credentials") + "generation", "credentials", "patronymic") _RENDER_KEYS = tuple(r.value for r in Role) + _DERIVED_VIEWS #: str.format keys initials() accepts: the three name-bearing roles. diff --git a/nameparser/_types.py b/nameparser/_types.py index 9e7787744..4e9b5ad85 100644 --- a/nameparser/_types.py +++ b/nameparser/_types.py @@ -903,9 +903,8 @@ class ReadingKind(StrEnum): #: An ordinary name word, given or family. NAME = "name" #: A patronymic or matronymic: a word naming the parent ("bin - #: Mohamad", "Ivanovich"), in whichever field the parse put it. - #: Added in 3.0 for the patronymic view; no reading of the parse - #: takes it yet. + #: Mohamad", "Ivanovich"), in whichever field the parse put it, the + #: words :attr:`ParsedName.patronymic` holds. Added in 3.0. PATRONYMIC = "patronymic" @@ -1388,6 +1387,38 @@ def credentials(self) -> str: def given_names(self) -> str: return self._text_for(Role.GIVEN, Role.MIDDLE) + @property + # rules/R9: "the words read as a patronymic, in whichever field + # the parse put them" (history: decisions/R9, #754) + def patronymic(self) -> str: + """The words read as a patronymic or matronymic ("bin + Mohamad", and "Ivanovich" where a policy turns on the East + Slavic rule), in whichever field the parse put them, which + keeps them; ``''`` for a name with none. Space-joined in + written order, the family's words in the order :attr:`family` + writes them.""" + parts: list[str | list[str]] = [] + family: list[str] | None = None + folded: list[str] = [] + for tok in self.tokens: + if (tok.reading is None + or tok.reading.kind is not ReadingKind.PATRONYMIC): + continue + if tok.role is Role.FAMILY: + if family is None: + family = [] + parts.append(family) + # a word folded into the family is written before it, + # as `family` writes it ('Mohamad, Mahathir bin') + (folded if FOLDED_TAG in tok.tags else family).append( + tok.text) + else: + parts.append(tok.text) + if family is not None: + family[:0] = folded + return " ".join(p if isinstance(p, str) else " ".join(p) + for p in parts) + def _suffix_view(self, generation: bool) -> str: # the `generation` / `credentials` partition of `suffix`, each # word placed by the reading it took ('D.' after 'Ph.' takes diff --git a/nameparser/locales/ru.py b/nameparser/locales/ru.py index 6b0738fa1..53ac1fe35 100644 --- a/nameparser/locales/ru.py +++ b/nameparser/locales/ru.py @@ -1,8 +1,9 @@ """The Russian locale pack (rule O1): policy-only -- it turns on the EAST_SLAVIC patronymic rule. The morphology data (-ovich/-ovna -endings, Cyrillic and transliterated) lives inside the rule -implementation in nameparser/_pipeline/_post_rules.py, not in the -Lexicon (mirrors v1's patronymic_name_order flag design, v1.3.0). +endings, Cyrillic and transliterated) lives beside the rule +implementation, in nameparser/_pipeline/_vocab.py's patterns, which +_post_rules.py's rotation and assemble's patronymic reading (rules/R9) +both ask, not in the Lexicon (mirrors v1's patronymic_name_order flag design, v1.3.0). Cyrillic titles/conjunctions are default-lexicon vocabulary (#269), not pack data (mechanisms.md#LOCALE-PACKS-PURE-DATA, the sorting rule). @@ -33,7 +34,7 @@ patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})), ) -# Ported by hand from nameparser/_pipeline/_post_rules.py's +# Ported by hand from nameparser/_pipeline/_vocab.py's # _EAST_SLAVIC/_EAST_SLAVIC_CYR (the rule is the source of truth; this # predicate only DECLARES the deviation surface for the non-interference # gate -- layering forbids importing the pipeline from a pack). Keep diff --git a/nameparser/locales/tr_az.py b/nameparser/locales/tr_az.py index c92fb75e3..5dc770bc7 100644 --- a/nameparser/locales/tr_az.py +++ b/nameparser/locales/tr_az.py @@ -1,7 +1,9 @@ """The Turkish/Azerbaijani locale pack (rule O2): policy-only -- it turns on the TURKIC patronymic rule. The marker data (oglu/qizi/ -uulu/kyzy and Cyrillic forms) lives inside the rule implementation in -nameparser/_pipeline/_post_rules.py (mirrors v1's flag design). +uulu/kyzy and Cyrillic forms) lives beside the rule implementation, in +nameparser/_pipeline/_vocab.py's patterns, which _post_rules.py's +rotation and assemble's patronymic reading (rules/R9) both ask +(mirrors v1's flag design). Data sources: the v1 Turkic patronymic rule (issue #185 and its test bank, tests/test_turkic_patronymic_order.py); the pack carries no @@ -28,7 +30,7 @@ patronymic_rules=frozenset({PatronymicRule.TURKIC})), ) -# Ported by hand from nameparser/_pipeline/_post_rules.py's +# Ported by hand from nameparser/_pipeline/_vocab.py's # _TURKIC/_TURKIC_CYR (the rule is the source of truth; this predicate # only DECLARES the deviation surface for the non-interference gate -- # layering forbids importing the pipeline from a pack). Keep both diff --git a/tests/test_east_slavic_patronymic_order.py b/tests/test_east_slavic_patronymic_order.py index 0658d2076..e88ddd912 100644 --- a/tests/test_east_slavic_patronymic_order.py +++ b/tests/test_east_slavic_patronymic_order.py @@ -1,6 +1,6 @@ from nameparser import PatronymicRule, parse -from nameparser._pipeline._post_rules import _EAST_SLAVIC as _LATIN -from nameparser._pipeline._post_rules import _EAST_SLAVIC_CYR as _CYRILLIC +from nameparser._pipeline._vocab import _EAST_SLAVIC as _LATIN +from nameparser._pipeline._vocab import _EAST_SLAVIC_CYR as _CYRILLIC from tests.base import FlaggedPolicyTestBase, ParsedNameTestBase _BOTH_RULES = {"patronymic_rules": {PatronymicRule.EAST_SLAVIC, diff --git a/tests/test_turkic_patronymic_order.py b/tests/test_turkic_patronymic_order.py index 859635c9a..df1fde675 100644 --- a/tests/test_turkic_patronymic_order.py +++ b/tests/test_turkic_patronymic_order.py @@ -1,5 +1,5 @@ from nameparser import PatronymicRule, parse -from nameparser._pipeline._post_rules import ( +from nameparser._pipeline._vocab import ( _EAST_SLAVIC, _EAST_SLAVIC_CYR, _TURKIC, diff --git a/tests/v2/data/parsedname_v28_patronymic.pickle b/tests/v2/data/parsedname_v28_patronymic.pickle new file mode 100644 index 0000000000000000000000000000000000000000..d53fbb9742dd7dab3ef995f84a64adf77ebd0994 GIT binary patch literal 352 zcmZ9IF;BxV5QT$CO9};nC=zTHQ#;Tfz+bS6P$@IIv?gbf#8K>2bU>m5bh!oP_`ldG z5fYxB{qB9b=lAgYaW7~No#l|p0!kyx$&+0d(l8y~`}^|FIb8F2!*4vINS7*6IiyU3 zDIi!>Hpmp30&+hZK6p{H2t@SB8|Rcd zm5np=qGswOYUOAjJaevv#PH~gs~CH`)v!4JQY3B_Y^$G3+g^YrqXp2#4A_9 z^|Wf?pme#?3y5#qha1RZl~lSi{EvLl)@$g;V(?49c0fP6Wjfl0l~wNZ##KD|0a?z4 AZ2$lO literal 0 HcmV?d00001 diff --git a/tests/v2/pipeline/test_assemble.py b/tests/v2/pipeline/test_assemble.py index 10dcddd1e..243992e4b 100644 --- a/tests/v2/pipeline/test_assemble.py +++ b/tests/v2/pipeline/test_assemble.py @@ -330,6 +330,22 @@ def test_a_field_tail_report_names_its_first_referent_s_field() -> None: "position"), ("de Mesnil Jean", Policy(name_order=FAMILY_FIRST), "de", "particle", "vocabulary"), + # a patronymic particle (R9) by the vocabulary, the words its + # run takes and the Turkic father's name by position, an ending or + # a marker by its shape + ("Mahathir bin Mohamad", None, "bin", "patronymic", "vocabulary"), + ("Mahathir bin Mohamad", None, "Mohamad", "patronymic", "position"), + ("Ahmad bin Abdul Rahman", None, "Abdul", "patronymic", "position"), + ("Mohamad, Mahathir bin", None, "Mohamad", "patronymic", "position"), + ("Fritz Freiherr und von Bar", None, "Bar", "particle", "vocabulary"), + ("bin Mohamad", None, "bin", "name", "declared"), + ("Ivan Ivanovich Ivanov", Policy(patronymic_rules={"east-slavic"}), # type: ignore[arg-type] + "Ivanovich", "patronymic", "shape"), + ("Ivan Ivanovich Ivanov", None, "Ivanovich", "name", "position"), + ("Ali Ahmad oglu", Policy(patronymic_rules={"turkic"}), # type: ignore[arg-type] + "oglu", "patronymic", "shape"), + ("Ali Ahmad oglu", Policy(patronymic_rules={"turkic"}), # type: ignore[arg-type] + "Ahmad", "patronymic", "position"), ] @@ -351,8 +367,11 @@ def test_every_parsed_token_has_a_reading_of_its_own_field(case: Case) -> None: for t in pn.tokens: assert t.reading is not None, t assert t.reading.role is t.role, t - # 4c's: no rule of the parse names a patronymic yet - assert t.reading.kind is not ReadingKind.PATRONYMIC, t + # a patronymic is listed only as a particle (R9); the words + # its run takes and an ending rule's are not + if (t.reading.kind is ReadingKind.PATRONYMIC + and t.reading.source is ReadingSource.VOCABULARY): + assert "particle" in t.tags, t # no file holds a reading by shape or by position if t.reading.source in (ReadingSource.SHAPE, ReadingSource.POSITION): assert t.reading.languages == (), t @@ -610,3 +629,45 @@ def test_an_alternative_names_the_files_holding_it() -> None: (amb,) = parse("Doe, John van DO").ambiguities assert amb.alternative is not None assert amb.alternative.languages == ("en", "nl") + + +def test_a_patronymic_particle_names_its_files_and_reports_a_patronymic( + ) -> None: + # the particle's reading (R9) names the files holding it as a + # patronymic, and a leading one read as a given name names the + # patronymic as the reading not taken, as the reading taken would + (bin_, mohamad) = parse("Mahathir bin Mohamad").tokens_for(Role.FAMILY) + assert bin_.reading is not None and bin_.reading.languages == ("ar", "ms") + assert mohamad.reading is not None and mohamad.reading.languages == () + (report,) = parse("bin Mohamad").ambiguities + assert report.kind is AmbiguityKind.PARTICLE_OR_GIVEN + assert report.alternative == Reading("patronymic", "family", "vocabulary") # type: ignore[arg-type] + assert report.alternative.languages == ("ar", "ms") + # a particle of another kind still names a particle + (other,) = parse("van Gogh").ambiguities + assert other.alternative == Reading("particle", "family", "vocabulary") # type: ignore[arg-type] + + +def test_every_particle_reads_patronymic_exactly_where_the_index_says( + ) -> None: + # One walk over every particle (R9): behind a given name and + # before a parent's name, a word reads `patronymic` iff a shipped + # file holds it as a patronymic particle, whatever its default + from nameparser._reading_index import holds_kind + lex = Lexicon.default() + words = sorted(lex.particles) + assert len(words) > 100 + found = set() + mismatched = [] + for word in words: + tok = parse(f"Ahmad {word} Hasan").tokens[1] + if tok.reading is None or tok.reading.kind.value not in ( + "particle", "patronymic"): + continue + is_patronymic = tok.reading.kind.value == "patronymic" + if is_patronymic: + found.add(word) + if is_patronymic is not holds_kind(word, "patronymic"): + mismatched.append((word, tok.reading)) + assert not mismatched, mismatched[:10] + assert {"bin", "binti", "ibn", "bar", "بن"} <= found diff --git a/tests/v2/rules_budget.json b/tests/v2/rules_budget.json index 9541f2a2d..16356a04f 100644 --- a/tests/v2/rules_budget.json +++ b/tests/v2/rules_budget.json @@ -57,6 +57,7 @@ "R6": {"words": 400, "then_words": 265, "because_sentences": 2, "except": 1, "precedence": 0, "accepted": 5, "accepted_words": 289}, "R7": {"words": 142, "then_words": 30, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 2, "accepted_words": 74}, "R8": {"words": 163, "then_words": 61, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 7, "accepted_words": 291}, + "R9": {"words": 347, "then_words": 110, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 7, "accepted_words": 262}, "S1": {"words": 306, "then_words": 26, "because_sentences": 1, "except": 2, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S2": {"words": 657, "then_words": 119, "because_sentences": 1, "except": 0, "precedence": 7, "accepted": 3, "accepted_words": 222}, "S3": {"words": 960, "then_words": 398, "because_sentences": 3, "except": 4, "precedence": 5, "accepted": 6, "accepted_words": 343}, diff --git a/tests/v2/rules_doc.py b/tests/v2/rules_doc.py index 8ccb1462b..8f6be2ada 100644 --- a/tests/v2/rules_doc.py +++ b/tests/v2/rules_doc.py @@ -64,7 +64,7 @@ ASSERTABLE_FIELDS = frozenset({ "title", "given", "middle", "family", "suffix", "nickname", "maiden", "family_base", "family_particles", "surnames", "given_names", - "generation", "credentials", "initials", "capitalized", "capitalized_forced", "str", + "generation", "credentials", "patronymic", "initials", "capitalized", "capitalized_forced", "str", "ambiguities", "pieces", "warns", "raises"}) diff --git a/tests/v2/test_cases.py b/tests/v2/test_cases.py index 61dc129ac..0c5b60fc5 100644 --- a/tests/v2/test_cases.py +++ b/tests/v2/test_cases.py @@ -1,6 +1,7 @@ """The runner over the shared case table, tests/v2/cases.py.""" import functools import re +from collections import Counter from typing import Any import pytest @@ -87,9 +88,14 @@ def test_the_family_and_the_suffix_partition_into_their_views( rules/R8's views partition the suffix the same way, its words exactly the generation's plus the credentials', and join this - walk rather than parsing the grid again. + walk rather than parsing the grid again; so does rules/R9's + patronymic, which names words the name fields keep. """ pn = _swept(case.id, order) + named = Counter(f"{pn.given} {pn.middle} {pn.family}".split()) + assert not Counter(pn.patronymic.split()) - named, ( + f"{case.text!r}: patronymic={pn.patronymic!r} names a word no " + f"name field holds") assert sorted(pn.suffix.replace(",", " ").split()) == sorted( (pn.generation + " " + pn.credentials).replace(",", " ").split()), ( f"{case.text!r}: suffix={pn.suffix!r} is not partitioned by " @@ -329,6 +335,7 @@ def test_the_field_sweep_sees_the_claims_it_checks() -> None: **dict.fromkeys((ReadingKind.CREDENTIAL, ReadingKind.GENERATION, ReadingKind.AGNOMEN, ReadingKind.POST_NOMINAL), Role.SUFFIX), ReadingKind.TITLE: Role.TITLE, ReadingKind.PARTICLE: Role.FAMILY, + ReadingKind.PATRONYMIC: Role.FAMILY, } #: A plain word of each kind the oracle below writes in place of the diff --git a/tests/v2/test_cldr_agreement.py b/tests/v2/test_cldr_agreement.py index a6c1016e6..09147ea92 100644 --- a/tests/v2/test_cldr_agreement.py +++ b/tests/v2/test_cldr_agreement.py @@ -117,8 +117,10 @@ def _summary_run(*args: str) -> tuple[int, str]: def test_the_summary_says_where_each_pending_field_is_scored() -> None: _, out = _summary_run() - assert "patronymic" in out and "(scored inside given_names)" in out - assert "(scored inside family)" in out + # `patronymic` is a view since 3.0's 4c (rules/R9), scored on its own + # beside the merged role; `surname2` waits for 4d + assert "(and scored inside given_names)" in out + assert "stated, not a ParsedName field yet (scored inside family)" in out def test_a_role_parsed_name_stops_answering_still_fails_loudly( @@ -186,12 +188,15 @@ def test_same(expected: str, got: str, text: str, result: bool) -> None: assert agreement.same(expected, got, text) is result -def test_got_roles_reads_the_six_views() -> None: +def test_got_roles_reads_the_six_views_and_each_pending_field_parsed_name_has( + ) -> None: roles = agreement.got_roles(parse("Dr. Paul von Fischer Jr.")) assert roles == {"title": "Dr.", "given_names": "Paul", "family": "von Fischer", "suffix": "Jr.", "family_particles": "von", - "family_base": "Fischer"} - assert tuple(roles) == agreement.ROLES + "family_base": "Fischer", "patronymic": ""} + assert tuple(roles) == (*agreement.ROLES, "patronymic") + assert agreement.got_roles(parse("Mahathir bin Mohamad"))["patronymic"] == ( + "bin Mohamad") def _row(text: str, shape: int | None, **fields: str) -> dict[str, object]: diff --git a/tests/v2/test_locales.py b/tests/v2/test_locales.py index 91e8f0d1b..91319705d 100644 --- a/tests/v2/test_locales.py +++ b/tests/v2/test_locales.py @@ -646,19 +646,19 @@ def test_pack_vocabulary_entries_are_single_words() -> None: f"whitespace and can never match") -def test_pack_marker_regexes_stay_in_sync_with_post_rules() -> None: +def test_pack_marker_regexes_stay_in_sync_with_the_pipeline() -> None: # ru.py/tr_az.py hand-copy their DEVIATES marker regexes from - # _pipeline._post_rules (layering forbids a pack importing the + # _pipeline._vocab (layering forbids a pack importing the # pipeline -- see each pack's module docstring). Assert pattern # equality mechanically so drift fails here, not at the L6 # non-interference gate. - from nameparser._pipeline import _post_rules + from nameparser._pipeline import _vocab for pack_regex, rule_regex in ( - (_ru._EAST_SLAVIC, _post_rules._EAST_SLAVIC), - (_ru._EAST_SLAVIC_CYR, _post_rules._EAST_SLAVIC_CYR), - (_tr_az._TURKIC, _post_rules._TURKIC), - (_tr_az._TURKIC_CYR, _post_rules._TURKIC_CYR), + (_ru._EAST_SLAVIC, _vocab._EAST_SLAVIC), + (_ru._EAST_SLAVIC_CYR, _vocab._EAST_SLAVIC_CYR), + (_tr_az._TURKIC, _vocab._TURKIC), + (_tr_az._TURKIC_CYR, _vocab._TURKIC_CYR), ): assert pack_regex.pattern == rule_regex.pattern assert pack_regex.flags == rule_regex.flags @@ -1014,7 +1014,7 @@ def test_rotators_cover_every_marker_branch(code: str) -> None: # this enforces them mechanically: every alternation branch of every # marker regex a pack defines (any module-level re.Pattern) must be # hit by some rotator token, so a regex edit that adds a branch - # (kept in sync with _post_rules by the pattern-equality test above) + # (kept in sync with _vocab by the pattern-equality test above) # fails HERE until a rotator name covers it. Anchoring follows the # pattern's own shape: '^(...)$' = whole-token marker, '(...)$' = # token ending. diff --git a/tests/v2/test_reading_index.py b/tests/v2/test_reading_index.py index 2b1043c5d..e71bfab47 100644 --- a/tests/v2/test_reading_index.py +++ b/tests/v2/test_reading_index.py @@ -278,3 +278,19 @@ def test_prefer_never_drops_a_reading_of_unknown_language() -> None: pool = [_r("bon", "fr", "particle"), _r("bon", "und", "particle"), _r("bon", "mul", "generation")] assert len(_kept(pool, {"fr"})) == 3 + + +@pytest.mark.parametrize("text,kind,held", [ + ("bin", "patronymic", True), + ("بن", "patronymic", True), + # `ben` is excluded as a patronymic (ar.toml) and `van` is another + # kind of particle; a word no file holds holds nothing + ("ben", "patronymic", False), + ("van", "patronymic", False), + ("zzq", "patronymic", False), + # a reading written `default = false` is held all the same + ("st", "generation", True), +]) +def test_holds_kind_reads_every_file_whatever_the_default( + text: str, kind: str, held: bool) -> None: + assert ri.holds_kind(text, kind) is held diff --git a/tests/v2/test_regex_sync.py b/tests/v2/test_regex_sync.py index 711c8d97c..95dc29af9 100644 --- a/tests/v2/test_regex_sync.py +++ b/tests/v2/test_regex_sync.py @@ -61,10 +61,10 @@ def test_roman_numeral_matches_config() -> None: def test_patronymic_patterns_match_config() -> None: pairs = ( - (_post_rules._EAST_SLAVIC, "east_slavic_patronymic"), - (_post_rules._EAST_SLAVIC_CYR, "east_slavic_patronymic_cyrillic"), - (_post_rules._TURKIC, "turkic_patronymic_marker"), - (_post_rules._TURKIC_CYR, "turkic_patronymic_marker_cyrillic"), + (_vocab._EAST_SLAVIC, "east_slavic_patronymic"), + (_vocab._EAST_SLAVIC_CYR, "east_slavic_patronymic_cyrillic"), + (_vocab._TURKIC, "turkic_patronymic_marker"), + (_vocab._TURKIC_CYR, "turkic_patronymic_marker_cyrillic"), ) for copy, key in pairs: source = _config.REGEXES[key] @@ -158,10 +158,10 @@ def test_dotted_initial_is_the_period_alternative_of_initial() -> None: # SAME object, imported for the report on a numeral before a # credential (#721) ("_assign", "_ROMAN"): "roman_numeral", - ("_post_rules", "_EAST_SLAVIC"): "east_slavic_patronymic", - ("_post_rules", "_EAST_SLAVIC_CYR"): "east_slavic_patronymic_cyrillic", - ("_post_rules", "_TURKIC"): "turkic_patronymic_marker", - ("_post_rules", "_TURKIC_CYR"): "turkic_patronymic_marker_cyrillic", + ("_vocab", "_EAST_SLAVIC"): "east_slavic_patronymic", + ("_vocab", "_EAST_SLAVIC_CYR"): "east_slavic_patronymic_cyrillic", + ("_vocab", "_TURKIC"): "turkic_patronymic_marker", + ("_vocab", "_TURKIC_CYR"): "turkic_patronymic_marker_cyrillic", # rules/O6's hyphens (#754): no config counterpart ("_post_rules", "_HYPHENS"): None, ("_render", "_SPACES"): "spaces", diff --git a/tests/v2/test_snapshot.py b/tests/v2/test_snapshot.py index e825f23da..5afa3a1d2 100644 --- a/tests/v2/test_snapshot.py +++ b/tests/v2/test_snapshot.py @@ -76,7 +76,8 @@ def test_a_snapshot_predating_the_case_views_is_not_compared_on_them() -> None: for k, r in new.items()} # a headerless snapshot records no alternative either assert snapshot.unrecorded_views(None, keys, old, new) == ( - *snapshot.CASE_VIEWS, "alternatives", *snapshot.SUFFIX_VIEWS) + *snapshot.CASE_VIEWS, "alternatives", *snapshot.SUFFIX_VIEWS, + *snapshot.PATRONYMIC_VIEW) assert not snapshot.grouped(old, new) # A snapshot that records a view on one row, or only the plain # view, still compares both: a name repair leaves alone records none. @@ -84,7 +85,7 @@ def test_a_snapshot_predating_the_case_views_is_not_compared_on_them() -> None: new = {snapshot.key(r): dict(r) for r in rows} current = {k: dict(r) for k, r in new.items()} assert snapshot.unrecorded_views(None, keys, current, new) == ( - "alternatives", *snapshot.SUFFIX_VIEWS) + "alternatives", *snapshot.SUFFIX_VIEWS, *snapshot.PATRONYMIC_VIEW) assert new == current assert not any("capitalized_force" in r for r in current.values()) @@ -103,7 +104,7 @@ def test_a_snapshot_predating_the_alternatives_is_not_compared_on_them( old = {k: {f: v for f, v in r.items() if f != "alternatives"} for k, r in new.items()} assert snapshot.unrecorded_views(None, keys, old, new) == ( - "alternatives", *snapshot.SUFFIX_VIEWS) + "alternatives", *snapshot.SUFFIX_VIEWS, *snapshot.PATRONYMIC_VIEW) assert not snapshot.grouped(old, new) rows = snapshot.read([("JOHN SMITH MA", None), ("Jack MA", None)]) new = {snapshot.key(r): dict(r) for r in rows} @@ -136,7 +137,8 @@ def test_a_snapshot_whose_names_report_nothing_still_records_alternatives( case_only = frozenset(snapshot.CASE_VIEWS) assert snapshot.unrecorded_views(snapshot.recorded(text), case_only, old, new) == ("alternatives", - *snapshot.SUFFIX_VIEWS) + *snapshot.SUFFIX_VIEWS, + *snapshot.PATRONYMIC_VIEW) assert not snapshot.grouped(old, new) @@ -153,6 +155,15 @@ def test_a_row_records_the_suffix_views_where_the_suffix_holds_a_generation( assert (both["generation"], both["credentials"]) == ("Jr.", "PhD") +def test_a_row_records_the_patronymic_view_where_it_is_not_empty() -> None: + # the default reads a patronymic particle's (R9); a name with none + # records no key + plain, nasab = snapshot.read([("John Smith", None), + ("Mahathir bin Mohamad", None)]) + assert "patronymic" not in plain + assert nasab["patronymic"] == "bin Mohamad" + + def test_a_lost_alternative_shows_beside_an_identical_one() -> None: # Each report's alternative is recorded in report order, so losing # one of two identical ones moves the row (#902's review); a set of diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index 7c94af88b..62df29fb7 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -1034,3 +1034,94 @@ def test_a_declared_delimiter_glued_to_a_generation_keeps_it_one( text.format(d=delimiter)) assert (pn.generation, pn.credentials) == ( generation.format(d=delimiter), "MD") + + +# -- the patronymic view (rules/R9, #754) -------------------------------- + +_RU = Parser(policy=Policy(patronymic_rules={"east-slavic"})) # type: ignore[arg-type] +_TR = Parser(policy=Policy(patronymic_rules={"turkic"})) # type: ignore[arg-type] + + +@pytest.mark.parametrize("parser,text,patronymic,field", [ + # the empty path: no particle, and a name written without one + (Parser(), "John Smith", "", "Smith"), + (Parser(), "Anwar Ibrahim", "", "Ibrahim"), + # a particle and its parent's name, the family keeping them + (Parser(), "Mahathir bin Mohamad", "bin Mohamad", "bin Mohamad"), + (Parser(), "Ahmad bin Abdul Rahman", "bin Abdul Rahman", "bin Abdul Rahman"), + (Parser(), "Abdul Rahman bin Ahmad", "bin Ahmad", "bin Ahmad"), + (Parser(), "Mahathir bin Mohamad Jr.", "bin Mohamad", "bin Mohamad"), + # two of them, across the middle and the family + (Parser(), "Ali bin Ahmad bin Hasan", "bin Ahmad bin Hasan", "bin Hasan"), + # P6's trailing particle: the family written before it, in the + # family's order + (Parser(), "Mohamad, Mahathir bin", "bin Mohamad", "bin Mohamad"), + (Parser(policy=Policy(name_order=FAMILY_FIRST)), "Mohamad Mahathir bin", + "bin Mohamad", "bin Mohamad"), + # the particle read as a name, or ending its field, names no parent + (Parser(), "Mahathir, bin Mohamad", "", "Mahathir"), + (Parser(), "Mohamad Mahathir bin", "", "bin"), + (Parser(), "Fritz Freiherr und von Bar", "", "Freiherr und von Bar"), + # a nickname inside the run is skipped + (Parser(), "Ahmad bin (Bob) Ali", "bin Ali", "bin Ali"), + # the East Slavic arm: a middle word carrying the ending + (_RU, "Ivan Ivanovich Ivanov", "Ivanovich", "Ivanov"), + (_RU, "Ivanov, Ivan Ivanovich", "Ivanovich", "Ivanov"), + (_RU, "Roman Abramovich", "", "Abramovich"), + (Parser(), "Ivan Ivanovich Ivanov", "", "Ivanov"), + # under middle_as_family the folded middle is still the middle + (Parser(policy=Policy(patronymic_rules={"east-slavic"}, # type: ignore[arg-type] + middle_as_family=True)), + "Ivan Ivanovich Ivanov", "Ivanovich", "Ivanovich Ivanov"), + (Parser(policy=Policy(middle_as_family=True)), + "Ali bin Ahmad Hasan Musa", "bin Ahmad Hasan Musa", "bin Ahmad Hasan Musa"), + # the Turkic arm: a marker and the name word before it, in its part + (_TR, "Ali Ahmad Vali oglu", "Vali oglu", "Ali"), + (_TR, "Ali Ahmad oglu", "Ahmad oglu", "oglu"), + (_TR, "Ali (Bob) oglu", "Ali oglu", "oglu"), + (_TR, "oglu Ali", "", "Ali"), + (_TR, "Aliyev, oglu", "", "Aliyev"), +]) +def test_the_patronymic_view_reads_a_particle_or_a_rule( + parser: Parser, text: str, patronymic: str, field: str) -> None: + pn = parser.parse(text) + assert pn.patronymic == patronymic + assert pn.family == field + + +def test_a_patronymic_word_with_no_reading_is_not_in_the_view() -> None: + # replace() records no reading (4a), so nothing says what the word + # was; a field it did not touch keeps its readings + pn = parse("Mahathir bin Mohamad") + assert pn.replace(family="bin Mohamad").patronymic == "" + assert pn.replace(given="Anwar").patronymic == "bin Mohamad" + + +def test_the_patronymic_view_is_a_render_key_and_not_a_field() -> None: + pn = parse("Mahathir bin Mohamad") + assert pn.render("{given} ({patronymic})") == "Mahathir (bin Mohamad)" + assert "patronymic" not in pn.as_dict() + assert "patronymic" not in repr(pn) + assert pn.comparison_key() == parse("Mahathir bin Mohamad").replace( + family="bin Mohamad").comparison_key() + with pytest.raises(TypeError): + pn.replace(patronymic="bin Ali") # type: ignore[call-arg] + + +def test_a_patronymic_parsedname_pickled_by_2_8_0_loads_equal() -> None: + """tests/v2/data/parsedname_v28_patronymic.pickle is `parse("Mahathir + bin Mohamad")` pickled (protocol 4) by the released 2.8.0 wheel, + made 2026-10-29 as the fixture above was. classify's + `vocab:patronymic` tag never reaches a published token, which + compares its tags, so the 2.x name is equal to its 3.0 parse and, + with no readings, has no patronymic. pickle.load is safe here: a + repo-controlled fixture.""" + import pickle + with open(Path(__file__).parent / "data" + / "parsedname_v28_patronymic.pickle", "rb") as f: + loaded = pickle.load(f) + now = parse("Mahathir bin Mohamad") + assert loaded == now + assert (loaded.patronymic, now.patronymic) == ("", "bin Mohamad") + assert not any(t.startswith("vocab:patronymic") + for tok in now.tokens for t in tok.tags) diff --git a/tools/differential/README.md b/tools/differential/README.md index 7d4feb983..1c1a5499c 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -41,7 +41,8 @@ report order, so a report losing its alternative beside an identical one still moves the line (#752 phase 2m). Since 3.0 it holds the suffix's two views (rules/R8, #326): `generation` where the suffix holds one, and `credentials` beside it where the suffix holds both, -so a line with neither has a suffix of credentials only, or none. +so a line with neither has a suffix of credentials only, or none, and +the `patronymic` view (rules/R9) where it is not empty. Empty values are left out, so a reading that gains a field shows as one added key. The file's first line is a header, `{"recorded": [...]}`, naming the keys added @@ -553,7 +554,7 @@ A second view measures the locale packs (#672). `LOCALE_FILES` in `cldr_agreemen **Adding a source.** Map only what the source states. The comparison reads a missing field as a statement that the role is empty, so a field the source leaves unstated must be mapped so that its role is not compared at all: #688's first build wrote a prefixless GND surname as `surname-core`, and 19 renderings recorded the parser's `de` in `Vélez de Guevara` as a disagreement no fix could clear; its fix then missed the same gap inside a several-word core after a prefix (`Zuylen van Nijevelt, Julius van`), so test each way a field can be left unstated, not the one that was reported. A field must also occur in its rendering's text, word by word (`test_every_committed_field_occurs_in_its_rendering`), or it is expected empty too. Size a stratum from the yield it has in the build, not on its own: strata that share a country take no one an earlier one took, so a yield measured alone overstates what the build gets. #862 measured its India strata one at a time, and Indian Urdu's 17 aligned names of 3,000 were 12 in build order, behind Bengali, Hindi and Marathi, exactly its quota; measure a new stratum with the strata before it filled to their quotas. A build must fail rather than shrink: give every stratum a floor, check a response's shape before caching it (an API error arrives as HTTP 200), and raise on a query that matches nothing. But tell a broken fetch from a fact about the data: #689's first fix raised on any item Wikidata reported missing, so one deleted name-part item anywhere in a pool would have stopped every rebuild, with `--refresh` advice that could not help; a missing part now only refuses the person stating it. A source sampled by script must check each text's script itself: Wikidata's `mul` labels are Latin, and the native-script build's first probe kept `Anushka Sharma` as a Hindi name. And give each refusal a test row that fails for the reason it names, not for another: that build's review found an all-Hebrew text standing for "a Hebrew point is not Arabic", which passed with the mark check deleted. A builder change has one oracle the suite cannot run, since it needs the local cache: rebuild offline from `$NAMEPARSER_TOOLS_CACHE` and diff the result against the committed fields file. The same review's mutation pass scored every surviving mutation that way, and four of them would have moved committed rows or the figures quoted from them with every test passing. The fetch (`fetch_bytes`: User-Agent, timeout, optional POST body and politeness delay), the cache (`cache_dir`, `cached_json`, which checks a response before caching it and writes atomically) and the two output files (`corpus_lines`, `fields_lines`, `write_corpus`) are shared in `corpus_build.py`, and the two Wikidata builders share their stratum loop (`build_strata` in `build_wikidata_corpus.py`: a person taken once across strata, the missing-person stop, the floor stop) as well as the pool check; a new builder imports them rather than copying another builder's, because a guard added to one copy is how #696 happened. -**The Wikidata source.** `build_wikidata_corpus.py` exists for the two fields no other open source labels, and states them in `wikidata_fields.jsonl` as `surname2` (P1950, CLDR's own field name) and `patronymic` (P5056, which CLDR has no field for), beside `given` (P735) and `surname` (P734); its disagreements are in `wikidata_agreement.json`. A person's written names are token-aligned to the parts rather than composed from them: a text is kept only where every word is claimed by exactly one part, a connective (`bin`, `i`, `y`) joining the part after it, and a woman's Russian surname may match the feminine form of its item's label. ParsedName has no `surname2` or `patronymic` attribute, so `cldr_agreement.py` lists them in `PENDING_ROLES` and scores them inside `family` and `given_names` respectively -- a patronymic is not a family name, so `Vigdís Finnbogadóttir` expects the family empty -- and also scores each on its own the day ParsedName gains an attribute of that name (whether the merged role then drops it is for the 3.0 design). Until then the summary says how many renderings state each. CLDR's `surname2` is in the same position. A stratum that falls below its floor, a query that matches nothing, a pool short of its size (unless the stratum is marked `whole`, which a full pool then contradicts) and a response that is not what was asked for each stop the build before anything is written; Iceland (270 of a pool of 600 on 2026-10-16, measured in the native paragraph below) and Malaysia are marked `whole`; Malaysia's floor is set below its quota because only 17 Malaysian public figures with a P5056 statement matched the query on 2026-10-16, and 9 of them aligned. Raw responses are cached outside the tree, in a `wikidata/` directory under `$NAMEPARSER_TOOLS_CACHE` (default `~/.cache/nameparser-tools`); `--refresh` fetches them again. +**The Wikidata source.** `build_wikidata_corpus.py` exists for the two fields no other open source labels, and states them in `wikidata_fields.jsonl` as `surname2` (P1950, CLDR's own field name) and `patronymic` (P5056, which CLDR has no field for), beside `given` (P735) and `surname` (P734); its disagreements are in `wikidata_agreement.json`. A person's written names are token-aligned to the parts rather than composed from them: a text is kept only where every word is claimed by exactly one part, a connective (`bin`, `i`, `y`) joining the part after it, and a woman's Russian surname may match the feminine form of its item's label. `cldr_agreement.py` lists `surname2` and `patronymic` in `PENDING_ROLES` and scores them inside `family` and `given_names` respectively -- a patronymic is not a family name, so `Vigdís Finnbogadóttir` expects the family empty -- and also scores each on its own once ParsedName has an attribute of that name: `patronymic` since 3.0's 4c (rules/R9), the merged scoring kept, while `surname2` waits for 4d and the summary says how many renderings state it. CLDR's `surname2` is in the same position. A stratum that falls below its floor, a query that matches nothing, a pool short of its size (unless the stratum is marked `whole`, which a full pool then contradicts) and a response that is not what was asked for each stop the build before anything is written; Iceland (270 of a pool of 600 on 2026-10-16, measured in the native paragraph below) and Malaysia are marked `whole`; Malaysia's floor is set below its quota because only 17 Malaysian public figures with a P5056 statement matched the query on 2026-10-16, and 9 of them aligned. Raw responses are cached outside the tree, in a `wikidata/` directory under `$NAMEPARSER_TOOLS_CACHE` (default `~/.cache/nameparser-tools`); `--refresh` fetches them again. **The native-script Wikidata source.** `build_wikidata_native_corpus.py` exists because every other real-name corpus is Latin script, apart from Wikidata's Russian patronymics, so the script-conditional readings (family-first Han, Hangul and kana; Korean surname segmentation) had been tested only against CLDR's invented names. No text holds an honorific, so the corpus does not exercise the CJK honorific peel; it shows only that the peel leaves plain names alone. It reuses `build_wikidata_corpus.py`'s fetches and token alignment and adds the following. Strata are a country and a language, not a property, and the query keeps people with a label in that language. A text is used only if every letter and combining mark is in the stratum's script (`mul` labels are Latin: `Anushka Sharma` would otherwise pass as a Hindi name). Unspaced text is aligned by character under the same exactly-one-part rule, and nothing is inferred: a Chinese item stating only its surname is left out rather than reading the rest of the text as the given name. Feminine surname forms are kept per language (Greek `Λελούδα` for `Λελούδας`, Bulgarian and Kazakh `-ова`, Ukrainian `-ська`), so the filter does not drop women. A Han, kana or Hangul text carries no shape, since the parser reads those scripts family-first by itself and that is what is being tested; pure katakana keeps the order it is written in, as the parser does. Each person gives one rendering, P1559 first, and its disagreements are in `wikidata_native_agreement.json`. On 2026-10-16 every spaced stratum agreed in every role except seven Arabic surnames of two words (`عبد العزيز`, `عز الدين`, `شكر الله`, `ذو الغنى`: the parser reads the first word as a middle name), a Persian double surname, and a Bulgarian name whose two stated family names include what is probably the father's name (`Динко Цветков Дерменджиев`); Hangul agreed on 120 of 120; on 2026-10-23 the 117 Devanagari and Urdu names then added agreed but for two Urdu surnames of two words (`رضا خان`, `اگنی ہوتری`), the Arabic surnames' class; all 85 new Devanagari names carry combining vowel signs and agreed, though none holds a period, so the gap rules/H2's Accepted clause records (a period-marked abbreviation in an abugida) is out of the corpus's reach, and the 32 Urdu names carry no combining mark at all. One Marathi name is stated with its parts swapped (`राहुल बजाज`, given `बजाज`) and so scores as family-first; Han read by default disagrees on every unspaced Chinese name and 49 of 60 Japanese ones, while the locale view reads 74/80 under `ZH` (six surnames it does not segment: `詹`, `卫`, `柯`, `茅`, `华`, `文`) and 59/60 under `JA`. Wikidata's P734 is a convention, not a fact, where the culture has none: an Arabic nisba (`البغدادي`) is entered as a family name, and the corpus scores it as one. `--lc-check` compares each name with the native-script variants of its Library of Congress record (P244), splitting by LC's comma or, in CJK, a space, and counts an LCCN LC no longer has; it runs after the build has written its files, and adds nothing to them and filters nothing from them. A pool that comes back shorter than its size stops the build unless its stratum is marked as the whole population (Thailand, Kazakhstan, Bangladesh, Hindi in India, Urdu in Pakistan, Nepal), so a query that starts matching less cannot drift the sample unseen; a stratum so marked whose pool comes back full stops it too, since the mark is then stale. The check is `pooled()` in `build_wikidata_corpus.py`, which both Wikidata builders call; re-pooled on 2026-10-16 (#696), the parent's Iceland returned 270 of a pool of 600 and Malaysia 17 of 240, so both are marked whole, and the other six filled their pools (es 900, mx 600, co 480, ru 660, su 360, ri 240). On 2026-10-16, 99 of the 910 names had an LC variant that splits them, and 96 agreed; the other three are Arabic names whose LC record names a person written the other way round (`Ibrāhīm, Ṣāliḥ` for Wikidata's `إبراهيم صالح`), so the P244 link or the order is suspect, not the split. LC writes most Han variants unspaced, so it confirms almost no Chinese or Japanese split. The first build's 907 corpus lines (904 names new to the snapshot; three Ukrainian names were already in `corpus_wikidata.jsonl`; the 2026-10-23 strata added 117 lines, all new to it) cost about 2.9 s in the corpus-reading test modules and about 0.6 s in the agreement tests, measured locally without coverage on 2026-10-16. Raw responses are cached under `$NAMEPARSER_TOOLS_CACHE`, in `wikidata/` and `lc/`. diff --git a/tools/differential/cldr_agreement.py b/tools/differential/cldr_agreement.py index 667b12768..ec611fa3e 100644 --- a/tools/differential/cldr_agreement.py +++ b/tools/differential/cldr_agreement.py @@ -49,16 +49,16 @@ Each record doubles as a measured worklist: every entry is a place the parser and the source read a name differently. -Two fields are stated but not yet scored on their own: `surname2` (a +Two fields are stated that the seven roles do not hold: `surname2` (a second surname: CLDR's `surname2`, Wikidata's P1950) and `patronymic` -(Wikidata's P5056, a field CLDR does not have). ParsedName has neither, -so each is scored inside the role it belongs to today -- `surname2` -inside `family`, as CLDR's own merge does, and `patronymic` inside -`given_names`, since a patronymic is not a family name (the GND and LC -convention: `Vigdís Finnbogadóttir` expects the family empty). They are -in PENDING_ROLES, and each is also scored on its own the day ParsedName -gains an attribute of that name; until then the summary says how many -renderings state it. Scoring a field on its own does not end the merged +(Wikidata's P5056, a field CLDR does not have). Each is scored inside +the role it belongs to -- `surname2` inside `family`, as CLDR's own +merge does, and `patronymic` inside `given_names`, since a patronymic +is not a family name (the GND and LC convention: `Vigdís +Finnbogadóttir` expects the family empty). They are in PENDING_ROLES, +and each is also scored on its own once ParsedName has an attribute of +that name (`patronymic` since 3.0's 4c, rules/R9); until then the +summary says how many renderings state it. Scoring a field on its own does not end the merged scoring; only a 3.0 decision to take the field out of `family` or `given_names` changes PENDING_ROLES. The conventions source's `honorific` is not pending: it is scored inside `suffix` by design, as @@ -447,7 +447,13 @@ def _summary(name: str, rows: list[dict[str, Any]], print(f"{role:17} {agree:5}/{totals[role]:<5} agree") answered = got_roles(parse_rendering("Ana Ruiz", None)) for role, merged in PENDING_ROLES.items(): - if totals[role] and role not in answered: + if not totals[role]: + continue + if role in answered: + agree = totals[role] - wrong[role] + print(f"{role:17} {agree:5}/{totals[role]:<5} agree " + f"(and scored inside {merged})") + else: print(f"{role:17} {totals[role]:5} stated, not a ParsedName field " f"yet (scored inside {merged})") problems = changes(read_record(record), current, {rendering_key(r) for r in rows}) diff --git a/tools/differential/conventions_agreement.json b/tools/differential/conventions_agreement.json index 6014b0fb5..075cff61d 100644 --- a/tools/differential/conventions_agreement.json +++ b/tools/differential/conventions_agreement.json @@ -60,6 +60,10 @@ "expected": "Hassan B. Yusof", "got": "Hassan B." }, + "patronymic": { + "expected": "B. Yusof", + "got": "" + }, "text": "Hassan B. Yusof" }, "conventions/Hassan bin Yusof/default": { @@ -82,8 +86,19 @@ "expected": "Hodan Farah Warsame", "got": "Hodan Farah" }, + "patronymic": { + "expected": "Farah Warsame", + "got": "" + }, "text": "Hodan Farah Warsame" }, + "conventions/Ivan Petrov Dimitrov/default": { + "patronymic": { + "expected": "Petrov", + "got": "" + }, + "text": "Ivan Petrov Dimitrov" + }, "conventions/K. Sivakumar/default": { "family": { "expected": "", @@ -93,6 +108,10 @@ "expected": "K. Sivakumar", "got": "K." }, + "patronymic": { + "expected": "K.", + "got": "" + }, "text": "K. Sivakumar" }, "conventions/Kavitha a/p Raman/default": { @@ -104,6 +123,10 @@ "expected": "Kavitha a/p Raman", "got": "Kavitha a/p" }, + "patronymic": { + "expected": "a/p Raman", + "got": "" + }, "text": "Kavitha a/p Raman" }, "conventions/Khun Malee/default": { @@ -200,6 +223,10 @@ "expected": "Suresh a/l Muthu", "got": "Suresh a/l" }, + "patronymic": { + "expected": "a/l Muthu", + "got": "" + }, "text": "Suresh a/l Muthu" }, "conventions/Suzuki san/FAMILY_FIRST": { diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 09f2fa996..9b5b34b17 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -31,13 +31,19 @@ "AHMAD IBN-ʿALĪ" "ANH DO" "Abdul MA Smith" +"Abdul Rahman bin Ahmad" "Abu Bakar" "Abu Bakar Salim" "Ahmad al-ʿAbbas" +"Ahmad bin Abdul Rahman" +"Ahmad bin “Bob” Ali" "Alexandre Dumas fils" "Ali Ahmad Vali oglu" "Ali Ahmad Vali oglu Jr." "Ali Ahmad oglu" +"Ali bin Ahmad bin Hasan" +"Aliyev, Heydar Alirza oglu" +"Aliyev, oglu" "Alves, Pedro Fils" "Alves, Pedro Neto" "Ana Fo" @@ -72,6 +78,7 @@ "Antonio Pavlović mlađi" "Antonio Pavlović mlađi Jones" "Antonio ‌Negri" +"Anwar Ibrahim" "Associate Dean Smith" "Asst. Vice Chancellor John Smith" "Attorney General of Minnesota" @@ -259,6 +266,8 @@ "Hudson, Rock" "II Van Johnson" "IVOI, PAUL D'" +"Ivan Ivanovich Ivanov" +"Ivanov, Ivan Ivanovich" "Ivoi, Paul d'" "J NEE SMITH" "J née Smith" @@ -663,6 +672,9 @@ "López, Ana García" "MSc Dr. med. univ." "Ma." +"Mahathir bin Mohamad" +"Mahathir bin Mohamad Jr." +"Mahathir, bin Mohamad" "Mai Le née Nguyen" "Major General John Smith" "Major Smith" @@ -694,8 +706,10 @@ "Mc Donald" "Md Abdul Karim" "Mesnil de" +"Mohamad, Mahathir bin" "Mohamed Ali Abd Allah" "Morse, Det. Insp. Jane" +"Moshe bar Yosef" "Mother Superior of the Convent Mary Smith" "Mr Buddha. Tamang" "Mr Deacon Smith" @@ -806,6 +820,7 @@ "Sheikh Mohammed bin Rashid" "Shin Ae-ra" "Shirley Maclaine" +"Sidorov Ivan Petrovich" "Sidorov Ivan Petrovich Jr." "Silva Neto, Pedro" "Silva, Neto" @@ -821,6 +836,7 @@ "Sir de" "Sir de Mesnil" "Sita Kumari K.C" +"Siti binti Abu Bakar" "Smith" "Smith (Jones)" "Smith (MA), John" @@ -980,6 +996,8 @@ "abdul V Smith" "abdul salam ahmed salem" "anh van do" +"bin Mohamad" +"bin Mohamad, Mahathir" "carod i" "da Silva do Amaral de Souza, Miguel" "de" @@ -999,6 +1017,7 @@ "dr. juan garcia III" "dr. née juan y lopez" "émile zola" +"ibn Ali Hasan" "ibn Awf abdul Rahman" "ina binti navalamar" "j" @@ -1076,6 +1095,7 @@ "maria silva-e-sousa" "mohamad ali smith" "née Jones" +"oglu Ali" "qc mp" "smith, de ma" "smith, j y lopez" @@ -1108,6 +1128,7 @@ "Хосе И Мария Сантос" "جورج بوش الابن" "سلمان، محمد" +"محمد بن سلمان" "राम शर्मा साहब मोहन" "शर्मा, राम बाबू" "প্রকৌশলী. Sen" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 802bd8eb2..8c2fff8f4 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -1,4 +1,4 @@ -{"recorded": ["capitalized", "capitalized_force", "alternatives", "generation", "credentials"]} +{"recorded": ["capitalized", "capitalized_force", "alternatives", "generation", "credentials", "patronymic"]} {"name": ""} {"name": "\"Rick\" Edmonds", "family": "Edmonds", "nickname": "Rick", "initials": "E."} {"name": "'Smitty' Dr. Jones", "title": "Dr.", "family": "Jones", "nickname": "Smitty", "initials": "J."} @@ -45,16 +45,17 @@ {"name": "Aarón Padilla Gutiérrez", "given": "Aarón", "middle": "Padilla", "family": "Gutiérrez", "initials": "A. P. G."} {"name": "Abd Berg", "given": "Abd", "family": "Berg", "initials": "A. B."} {"name": "Abd Berg née Jones", "given": "Abd", "family": "Berg", "maiden": "Jones", "initials": "A. B."} -{"name": "Abd al-Aziz ibn Muhammad", "given": "Abd al-Aziz", "family": "ibn Muhammad", "initials": "A. a. M.", "capitalized_force": "Abd Al-Aziz ibn Muhammad"} +{"name": "Abd al-Aziz ibn Muhammad", "given": "Abd al-Aziz", "family": "ibn Muhammad", "initials": "A. a. M.", "capitalized_force": "Abd Al-Aziz ibn Muhammad", "patronymic": "ibn Muhammad"} {"name": "Abd van der Berg", "given": "Abd", "family": "van der Berg", "initials": "A. B."} {"name": "Abd van der Berg née Jones", "given": "Abd", "family": "van der Berg", "maiden": "Jones", "initials": "A. B."} {"name": "Abdul Aziz Yassin", "given": "Abdul Aziz", "family": "Yassin", "initials": "A. A. Y."} {"name": "Abdul MA Smith", "given": "Abdul MA", "family": "Smith", "initials": "A. M. S.", "capitalized_force": "Abdul Ma Smith"} +{"name": "Abdul Rahman bin Ahmad", "given": "Abdul Rahman", "family": "bin Ahmad", "initials": "A. R. A.", "patronymic": "bin Ahmad"} {"name": "Abdul Razak Hussein", "given": "Abdul Razak", "family": "Hussein", "initials": "A. R. H."} {"name": "Abdul Salam Ahmed Salem, MD", "given": "Abdul Salam", "middle": "Ahmed", "family": "Salem", "suffix": "MD", "initials": "A. S. A. S."} {"name": "Abdul Salam Hassan, MD", "given": "Abdul Salam", "family": "Hassan", "suffix": "MD", "initials": "A. S. H."} {"name": "Abdul Salam, MD", "given": "Abdul", "family": "Salam", "suffix": "MD", "initials": "A. S."} -{"name": "Abdullah bin Haji Ahmad Badawi", "given": "Abdullah", "family": "bin Haji Ahmad Badawi", "initials": "A. H. A. B."} +{"name": "Abdullah bin Haji Ahmad Badawi", "given": "Abdullah", "family": "bin Haji Ahmad Badawi", "initials": "A. H. A. B.", "patronymic": "bin Haji Ahmad Badawi"} {"name": "Abe, Shinnosuke", "given": "Shinnosuke", "family": "Abe", "initials": "S. A."} {"name": "Abraham van den Hecken", "given": "Abraham", "family": "van den Hecken", "initials": "A. H."} {"name": "Abu Bakar", "given": "Abu", "family": "Bakar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "A. B."} @@ -84,7 +85,7 @@ {"name": "Adel López Gómez", "given": "Adel", "middle": "López", "family": "Gómez", "initials": "A. L. G."} {"name": "Adeolu Adegboyega Akintola", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} {"name": "Adiel Ascanio Sepúlveda", "given": "Adiel", "middle": "Ascanio", "family": "Sepúlveda", "initials": "A. A. S."} -{"name": "Adinata, Dwi Putro bin", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. P. A."} +{"name": "Adinata, Dwi Putro bin", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. P. A.", "patronymic": "bin Adinata"} {"name": "Adler Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Adler", "initials": "I. A."} {"name": "Adler İren", "order": "FAMILY_FIRST", "given": "İren", "family": "Adler", "initials": "İ. A."} {"name": "Adler, Irene", "given": "Irene", "family": "Adler", "initials": "I. A."} @@ -106,19 +107,21 @@ {"name": "Agustín Gómez Campos", "given": "Agustín", "middle": "Gómez", "family": "Campos", "initials": "A. G. C."} {"name": "Agustín Yáñez y Girona", "given": "Agustín", "family": "Yáñez y Girona", "initials": "A. Y. G."} {"name": "Ahmad Al-Hadi", "given": "Ahmad", "family": "Al-Hadi", "initials": "A. A.", "capitalized_force": "Ahmad al-Hadi"} -{"name": "Ahmad Albar bin Akbar", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "initials": "A. A. A."} +{"name": "Ahmad Albar bin Akbar", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "initials": "A. A. A.", "patronymic": "bin Akbar"} {"name": "Ahmad Jayadi, CHA", "given": "Ahmad", "family": "Jayadi", "suffix": "CHA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "A. J."} {"name": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah", "given": "Ahmad", "middle": "Shah Al-Musta’in Billah ibni Almarhum Sultan", "family": "Abu Bakar Riayatuddin Al-Mu’azzam Shah", "initials": "A. S. A. B. i. A. S. B. R. A. S.", "capitalized_force": "Ahmad Shah al-Musta’In Billah Ibni Almarhum Sultan abu Bakar Riayatuddin al-Mu’Azzam Shah"} {"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 “Bob” Ali", "given": "Ahmad", "family": "bin Ali", "nickname": "Bob", "initials": "A. A.", "patronymic": "bin Ali"} {"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."} {"name": "Aita Caduff", "given": "Aita", "family": "Caduff", "initials": "A. C."} {"name": "Aitken, Robert", "given": "Robert", "family": "Aitken", "initials": "R. A."} {"name": "Ajay Reddy B", "given": "Ajay", "middle": "Reddy", "family": "B", "initials": "A. R. B."} -{"name": "Akbar, Ahmad Albar bin", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A. A."} -{"name": "Akbar, Ahmad Albar bin", "order": "FAMILY_FIRST", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A. A."} +{"name": "Akbar, Ahmad Albar bin", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A. A.", "patronymic": "bin Akbar"} +{"name": "Akbar, Ahmad Albar bin", "order": "FAMILY_FIRST", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A. A.", "patronymic": "bin Akbar"} {"name": "Akintola Adeolu Adegboyega", "order": "FAMILY_FIRST", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} {"name": "Akintola, Adeolu Adegboyega", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} {"name": "Al Arnold Gore, Jr.", "given": "Al", "middle": "Arnold", "family": "Gore", "suffix": "Jr.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "A. A. G.", "generation": "Jr."} @@ -150,6 +153,7 @@ {"name": "Ali Amina", "order": "FAMILY_FIRST", "given": "Amina", "family": "Ali", "initials": "A. A."} {"name": "Ali Juma Hamisi", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} {"name": "Ali Kara Oğlu", "given": "Ali", "middle": "Kara", "family": "Oğlu", "initials": "A. K. O."} +{"name": "Ali bin Ahmad bin Hasan", "given": "Ali", "middle": "bin Ahmad", "family": "bin Hasan", "initials": "A. A. H.", "patronymic": "bin Ahmad bin Hasan"} {"name": "Ali, Amina", "given": "Amina", "family": "Ali", "initials": "A. A."} {"name": "Alice Rühle-Gerstel", "given": "Alice", "family": "Rühle-Gerstel", "initials": "A. R."} {"name": "Alina", "given": "Alina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} @@ -157,6 +161,8 @@ {"name": "Alisher Salimovich Qodirov Deputat", "given": "Alisher", "middle": "Salimovich Qodirov", "family": "Deputat", "initials": "A. S. Q. D."} {"name": "Alisher Sattorov", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} {"name": "Aliyev Vusal Said oglu", "given": "Aliyev", "middle": "Vusal Said", "family": "oglu", "initials": "A. V. S. o.", "capitalized_force": "Aliyev Vusal Said Oglu"} +{"name": "Aliyev, Heydar Alirza oglu", "given": "Heydar", "middle": "Alirza oglu", "family": "Aliyev", "initials": "H. A. o. A.", "capitalized_force": "Heydar Alirza Oglu Aliyev"} +{"name": "Aliyev, oglu", "given": "oglu", "family": "Aliyev", "initials": "o. A.", "capitalized_force": "Oglu Aliyev"} {"name": "Almonde, Philips van", "given": "Philips", "family": "van Almonde", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "P. A."} {"name": "Alois von und zu Liechtenstein", "given": "Alois", "family": "von und zu Liechtenstein", "initials": "A. L."} {"name": "Alois von und zu und von Liechtenstein", "given": "Alois", "family": "von und zu und von Liechtenstein", "initials": "A. L."} @@ -273,7 +279,7 @@ {"name": "Antonio dos Santos Cabral", "given": "Antonio", "family": "dos Santos Cabral", "initials": "A. S. C."} {"name": "Antonio ‌Negri", "given": "Antonio", "family": "‌Negri", "initials": "A. ‌."} {"name": "Antonius van Dale", "given": "Antonius", "family": "van Dale", "initials": "A. D."} -{"name": "Anuar bin Malek", "given": "Anuar", "family": "bin Malek", "initials": "A. M."} +{"name": "Anuar bin Malek", "given": "Anuar", "family": "bin Malek", "initials": "A. M.", "patronymic": "bin Malek"} {"name": "Anwar Ibrahim", "given": "Anwar", "family": "Ibrahim", "initials": "A. I."} {"name": "Arcadio Poveda Ricalde", "given": "Arcadio", "middle": "Poveda", "family": "Ricalde", "initials": "A. P. R."} {"name": "Arco, Giovanni Battista Gherardo d'", "given": "Giovanni", "middle": "Battista Gherardo", "family": "d' Arco", "initials": "G. B. G. A."} @@ -332,8 +338,8 @@ {"name": "Balóch Karima", "order": "FAMILY_FIRST", "given": "Karima", "family": "Balóch", "initials": "K. B."} {"name": "Balóch Karima Mehráb", "order": "FAMILY_FIRST", "given": "Karima", "middle": "Mehráb", "family": "Balóch", "initials": "K. M. B."} {"name": "Balóch Wj. Monir Mómen Monir Mómen", "order": "FAMILY_FIRST", "given": "Wj.", "middle": "Monir Mómen Monir Mómen", "family": "Balóch", "initials": "W. M. M. M. M. B."} -{"name": "Bapak Adinata, Dwi Putro bin MP", "order": "FAMILY_FIRST", "title": "Bapak", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. P. A."} -{"name": "Bapak Dwi Putro bin Adinata MP", "title": "Bapak", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "suffix": "MP", "initials": "D. P. A."} +{"name": "Bapak Adinata, Dwi Putro bin MP", "order": "FAMILY_FIRST", "title": "Bapak", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. P. A.", "patronymic": "bin Adinata"} +{"name": "Bapak Dwi Putro bin Adinata MP", "title": "Bapak", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "suffix": "MP", "initials": "D. P. A.", "patronymic": "bin Adinata"} {"name": "Barbara Brukalska", "given": "Barbara", "family": "Brukalska", "initials": "B. B."} {"name": "Barbara Scharf", "given": "Barbara", "family": "Scharf", "initials": "B. S."} {"name": "Barbara Scharf, CPPM", "given": "Barbara", "family": "Scharf", "suffix": "CPPM", "initials": "B. S."} @@ -915,7 +921,7 @@ {"name": "Elisa Ruiz Romero", "given": "Elisa", "middle": "Ruiz", "family": "Romero", "initials": "E. R. R."} {"name": "Eliseo Jiménez Ruiz", "given": "Eliseo", "middle": "Jiménez", "family": "Ruiz", "initials": "E. J. R."} {"name": "Elizabeth Margot Schreiber Castañón", "given": "Elizabeth", "middle": "Margot Schreiber", "family": "Castañón", "initials": "E. M. S. C."} -{"name": "Elizabeth Windsor Maria Teresa bin Stromae MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S."} +{"name": "Elizabeth Windsor Maria Teresa bin Stromae MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S.", "patronymic": "bin Stromae"} {"name": "Elslande, Renaat van", "given": "Renaat", "family": "van Elslande", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "R. E."} {"name": "Elvia Rangel de la Fuente", "given": "Elvia", "middle": "Rangel", "family": "de la Fuente", "initials": "E. R. F."} {"name": "Elvira Palafox Herrán", "given": "Elvira", "middle": "Palafox", "family": "Herrán", "initials": "E. P. H."} @@ -1240,7 +1246,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 bin Yusof", "given": "Hassan", "family": "bin Yusof", "initials": "H. Y."} +{"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. Á."} {"name": "Hebe de Bonafini", "given": "Hebe", "family": "de Bonafini", "initials": "H. B."} @@ -1360,10 +1366,12 @@ {"name": "Ivan", "given": "Ivan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "I."} {"name": "Ivan", "order": "FAMILY_FIRST", "family": "Ivan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "I."} {"name": "Ivan Ivanov", "given": "Ivan", "family": "Ivanov", "initials": "I. I."} +{"name": "Ivan Ivanovich Ivanov", "given": "Ivan", "middle": "Ivanovich", "family": "Ivanov", "initials": "I. I. I."} {"name": "Ivan Petrov Dimitrov", "given": "Ivan", "middle": "Petrov", "family": "Dimitrov", "initials": "I. P. D."} {"name": "Ivan Tham Jun Hoe", "given": "Ivan", "middle": "Tham Jun", "family": "Hoe", "initials": "I. T. J. H."} {"name": "Ivanov Ivan", "given": "Ivanov", "family": "Ivan", "initials": "I. I."} {"name": "Ivanov Ivan Ivanovich", "given": "Ivanov", "middle": "Ivan", "family": "Ivanovich", "initials": "I. I. I."} +{"name": "Ivanov, Ivan Ivanovich", "given": "Ivan", "middle": "Ivanovich", "family": "Ivanov", "initials": "I. I. I."} {"name": "Ivoi, Paul d'", "given": "Paul", "family": "d' Ivoi", "initials": "P. I."} {"name": "Iván Duque Márquez", "given": "Iván", "middle": "Duque", "family": "Márquez", "initials": "I. D. M."} {"name": "Iñaki", "given": "Iñaki", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "I."} @@ -2402,7 +2410,9 @@ {"name": "Magnus I.", "given": "Magnus", "family": "I.", "initials": "M. I."} {"name": "Magnús Eiríksson", "given": "Magnús", "family": "Eiríksson", "initials": "M. E."} {"name": "Magnús Jónsson", "given": "Magnús", "family": "Jónsson", "initials": "M. J."} -{"name": "Mahathir bin Mohamad", "given": "Mahathir", "family": "bin Mohamad", "initials": "M. M."} +{"name": "Mahathir bin Mohamad", "given": "Mahathir", "family": "bin Mohamad", "initials": "M. M.", "patronymic": "bin Mohamad"} +{"name": "Mahathir bin Mohamad Jr.", "given": "Mahathir", "family": "bin Mohamad", "suffix": "Jr.", "initials": "M. M.", "generation": "Jr.", "patronymic": "bin Mohamad"} +{"name": "Mahathir, bin Mohamad", "given": "bin", "middle": "Mohamad", "family": "Mahathir", "initials": "b. M. M.", "capitalized_force": "Bin Mohamad Mahathir"} {"name": "Maheen Farooqi, ACA", "given": "Maheen", "family": "Farooqi", "suffix": "ACA", "initials": "M. F."} {"name": "Mahmud II.", "given": "Mahmud", "suffix": "II.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M.", "generation": "II."} {"name": "Mai Le née Nguyen", "given": "Mai", "family": "Le", "maiden": "Nguyen", "initials": "M. L."} @@ -2613,10 +2623,11 @@ {"name": "Mohamad X Surname", "given": "Mohamad", "middle": "X", "family": "Surname", "initials": "M. X. S."} {"name": "Mohamad Zein El Dine", "given": "Mohamad", "middle": "Zein El", "family": "Dine", "initials": "M. Z. E. D."} {"name": "Mohamad Zeineddine", "given": "Mohamad", "family": "Zeineddine", "initials": "M. Z."} +{"name": "Mohamad, Mahathir bin", "given": "Mahathir", "family": "bin Mohamad", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. M.", "patronymic": "bin Mohamad"} {"name": "Mohamed Ali Abd Allah", "given": "Mohamed", "middle": "Ali Abd", "family": "Allah", "initials": "M. A. A. A."} {"name": "Mohamed Khir Johari", "given": "Mohamed", "middle": "Khir", "family": "Johari", "initials": "M. K. J."} -{"name": "Mohamed bin Hamzah", "given": "Mohamed", "family": "bin Hamzah", "initials": "M. H."} -{"name": "Mohd Najib bin Abdul Razak", "given": "Mohd", "middle": "Najib", "family": "bin Abdul Razak", "initials": "M. N. A. R."} +{"name": "Mohamed bin Hamzah", "given": "Mohamed", "family": "bin Hamzah", "initials": "M. H.", "patronymic": "bin Hamzah"} +{"name": "Mohd Najib bin Abdul Razak", "given": "Mohd", "middle": "Najib", "family": "bin Abdul Razak", "initials": "M. N. A. R.", "patronymic": "bin Abdul Razak"} {"name": "Molnar, Thomas", "given": "Thomas", "family": "Molnar", "initials": "T. M."} {"name": "Monsieur Jean Dupont", "title": "Monsieur", "given": "Jean", "family": "Dupont", "initials": "J. D."} {"name": "Montereul, Jean de", "given": "Jean", "family": "de Montereul", "initials": "J. M."} @@ -2626,6 +2637,7 @@ {"name": "Moreno Casamitjana, Antonio", "given": "Antonio", "family": "Moreno Casamitjana", "initials": "A. M. C."} {"name": "Morris, Robert", "given": "Robert", "family": "Morris", "initials": "R. M."} {"name": "Morse, Det. Insp. Jane", "title": "Det. Insp.", "given": "Jane", "family": "Morse", "initials": "J. M."} +{"name": "Moshe bar Yosef", "given": "Moshe", "family": "bar Yosef", "initials": "M. Y.", "patronymic": "bar Yosef"} {"name": "Mother Superior of the Convent Mary Smith", "title": "Mother Superior of the Convent", "given": "Mary", "family": "Smith", "initials": "M. S."} {"name": "Mozart Wolfgang Amadeus", "order": "FAMILY_FIRST", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} {"name": "Mozart, Wolfgang Amadeus", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} @@ -3140,19 +3152,20 @@ {"name": "Señora María García", "title": "Señora", "given": "María", "family": "García", "initials": "M. G."} {"name": "Sgt Marshal Smith", "title": "Sgt", "given": "Marshal", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "M. S."} {"name": "Shahnon Ahmad", "given": "Shahnon", "family": "Ahmad", "initials": "S. A."} -{"name": "Shaikh Hamad bin Isa", "given": "Shaikh", "middle": "Hamad", "family": "bin Isa", "initials": "S. H. I."} +{"name": "Shaikh Hamad bin Isa", "given": "Shaikh", "middle": "Hamad", "family": "bin Isa", "initials": "S. H. I.", "patronymic": "bin Isa"} {"name": "Shaikh. Sultan Khan", "title": "Shaikh.", "given": "Sultan", "family": "Khan", "initials": "S. K."} {"name": "Shanti Gaonkar", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} {"name": "Shehu Muhammed", "given": "Shehu", "family": "Muhammed", "initials": "S. M."} {"name": "Sheik Abu Bakar", "given": "Sheik", "family": "Abu Bakar", "initials": "S. B.", "capitalized_force": "Sheik abu Bakar"} {"name": "Sheik abdul salam", "given": "Sheik", "middle": "abdul", "family": "salam", "initials": "S. a. s.", "capitalized_force": "Sheik Abdul Salam"} -{"name": "Sheikh Mohammed bin Rashid", "given": "Sheikh", "middle": "Mohammed", "family": "bin Rashid", "initials": "S. M. R."} +{"name": "Sheikh Mohammed bin Rashid", "given": "Sheikh", "middle": "Mohammed", "family": "bin Rashid", "initials": "S. M. R.", "patronymic": "bin Rashid"} {"name": "Shemuʾel ben Ḥofni", "given": "Shemuʾel", "middle": "ben", "family": "Ḥofni", "initials": "S. b. Ḥ.", "capitalized_force": "Shemuʾel Ben Ḥofni"} {"name": "Shin Ae-ra", "given": "Shin", "family": "Ae-ra", "initials": "S. A.", "capitalized_force": "Shin Ae-Ra"} {"name": "Shirley Maclaine", "given": "Shirley", "family": "Maclaine", "initials": "S. M.", "capitalized_force": "Shirley MacLaine"} {"name": "Shree Lalit Kumaar Bharati Jr, MP", "given": "Shree", "middle": "Lalit Kumaar", "family": "Bharati", "suffix": "Jr, MP", "initials": "S. L. K. B.", "generation": "Jr", "credentials": "MP"} {"name": "Shtober Zaziliya Hemish", "order": "FAMILY_FIRST", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} {"name": "Shtober, Zaziliya Hemish", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} +{"name": "Sidorov Ivan Petrovich", "given": "Sidorov", "middle": "Ivan", "family": "Petrovich", "initials": "S. I. P."} {"name": "Sidorov Ivan Petrovich Jr.", "given": "Sidorov", "middle": "Ivan", "family": "Petrovich", "suffix": "Jr.", "initials": "S. I. P.", "generation": "Jr."} {"name": "Sig. Fabio Massimo Carlo Alberto Martini II, AD", "title": "Sig.", "given": "Fabio", "middle": "Massimo Carlo Alberto", "family": "Martini", "suffix": "II, AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "F. M. C. A. M.", "generation": "II", "credentials": "AD"} {"name": "Sig. Martini Fabio Massimo Carlo Alberto II, AD", "order": "FAMILY_FIRST", "title": "Sig.", "given": "Fabio", "middle": "Massimo Carlo Alberto", "family": "Martini", "suffix": "II, AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "F. M. C. A. M.", "generation": "II", "credentials": "AD"} @@ -3180,7 +3193,7 @@ {"name": "Silverio Cañada Acebal", "given": "Silverio", "middle": "Cañada", "family": "Acebal", "initials": "S. C. A."} {"name": "Silverio Franconetti y Aguilar", "given": "Silverio", "family": "Franconetti y Aguilar", "initials": "S. F. A."} {"name": "Silvije Strahimir Kranjčević", "given": "Silvije", "middle": "Strahimir", "family": "Kranjčević", "initials": "S. S. K."} -{"name": "Simeon bar Sabbaʿe", "given": "Simeon", "family": "bar Sabbaʿe", "initials": "S. S."} +{"name": "Simeon bar Sabbaʿe", "given": "Simeon", "family": "bar Sabbaʿe", "initials": "S. S.", "patronymic": "bar Sabbaʿe"} {"name": "Simkoyê Şikak", "given": "Simkoyê", "family": "Şikak", "initials": "S. Ş."} {"name": "Simonyi von Vitézvár, Lajos", "given": "Lajos", "family": "Simonyi von Vitézvár", "initials": "L. S. V."} {"name": "Simón Mesa Soto", "given": "Simón", "middle": "Mesa", "family": "Soto", "initials": "S. M. S."} @@ -3209,6 +3222,7 @@ {"name": "Sister Mary", "title": "Sister", "given": "Mary", "initials": "M."} {"name": "Sister Souljah", "title": "Sister", "given": "Souljah", "initials": "S."} {"name": "Sita Kumari K.C", "given": "Sita", "middle": "Kumari", "family": "K.C", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "S. K. K."} +{"name": "Siti binti Abu Bakar", "given": "Siti", "family": "binti Abu Bakar", "initials": "S. B.", "capitalized_force": "Siti binti abu Bakar", "patronymic": "binti Abu Bakar"} {"name": "Siv Friðleifsdóttir", "given": "Siv", "family": "Friðleifsdóttir", "initials": "S. F."} {"name": "Skapti Ólafsson", "given": "Skapti", "family": "Ólafsson", "initials": "S. Ó."} {"name": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður", "order": "FAMILY_FIRST", "given": "Hr.", "middle": "Geir Jón Magnús Brynjar", "family": "Skeggjason", "suffix": "yngri, þingmaður", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "H. G. J. M. B. S.", "capitalized_force": "Skeggjason Hr. Geir Jón Magnús Brynjar Yngri, Þingmaður", "generation": "yngri", "credentials": "þingmaður"} @@ -3776,8 +3790,10 @@ {"name": "and van Buren", "given": "and van", "family": "Buren", "initials": "a. v. B.", "capitalized_force": "and Van Buren"} {"name": "anh van do", "given": "anh", "family": "van do", "initials": "a. v. d.", "capitalized": "Anh Van Do"} {"name": "anh van mc", "given": "anh", "family": "van mc", "initials": "a. v. m.", "capitalized": "Anh Van Mc"} -{"name": "bin Stromae Elizabeth Windsor Maria Teresa MP", "order": "FAMILY_FIRST", "given": "Stromae", "middle": "Elizabeth Windsor Maria Teresa", "family": "bin", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "S. E. W. M. T. b.", "capitalized_force": "Bin Stromae Elizabeth Windsor Maria Teresa MP"} -{"name": "bin Stromae, Elizabeth Windsor Maria Teresa MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S."} +{"name": "bin Mohamad", "given": "bin", "family": "Mohamad", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: patronymic as family (vocabulary)"], "initials": "b. M.", "capitalized_force": "Bin Mohamad"} +{"name": "bin Mohamad, Mahathir", "given": "Mahathir", "family": "bin Mohamad", "initials": "M. M.", "patronymic": "bin Mohamad"} +{"name": "bin Stromae Elizabeth Windsor Maria Teresa MP", "order": "FAMILY_FIRST", "given": "Stromae", "middle": "Elizabeth Windsor Maria Teresa", "family": "bin", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: patronymic as family (vocabulary)"], "initials": "S. E. W. M. T. b.", "capitalized_force": "Bin Stromae Elizabeth Windsor Maria Teresa MP"} +{"name": "bin Stromae, Elizabeth Windsor Maria Teresa MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S.", "patronymic": "bin Stromae"} {"name": "bob v. de la macdole-eisenhower phd", "given": "bob", "middle": "v.", "family": "de la macdole-eisenhower", "suffix": "phd", "initials": "b. v. m.", "capitalized": "Bob V. de la MacDole-Eisenhower PhD"} {"name": "carod i", "given": "carod", "family": "i", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "c. i.", "capitalized": "Carod I"} {"name": "da Silva Sr. José Pedro Eduardo Jr., MP", "order": "FAMILY_FIRST", "given": "Silva", "middle": "Sr. José Pedro Eduardo", "family": "da", "suffix": "Jr., MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "S. S. J. P. E. d.", "capitalized_force": "Da Silva Sr. José Pedro Eduardo Jr., MP", "generation": "Jr.", "credentials": "MP"} @@ -3864,8 +3880,9 @@ {"name": "g. Janez Peter Novak ml., univ. dipl. inž.", "title": "univ. dipl. inž.", "given": "g.", "middle": "Janez Peter Novak", "family": "ml.", "initials": "g. J. P. N. m.", "capitalized_force": "Univ. Dipl. Inž. G. Janez Peter Novak Ml."} {"name": "g. Novak Janez Peter, univ. dipl. inž.", "order": "FAMILY_FIRST", "title": "univ. dipl. inž.", "given": "Novak", "middle": "Janez Peter", "family": "g.", "initials": "N. J. P. g.", "capitalized_force": "Univ. Dipl. Inž. G. Novak Janez Peter"} {"name": "hr. Hans Henrik Sven Åge Møller, MF", "title": "hr.", "given": "MF", "family": "Hans Henrik Sven Åge Møller", "initials": "M. H. H. S. Å. M.", "capitalized_force": "Hr. Mf Hans Henrik Sven Åge Møller"} -{"name": "ibn Awf abdul Rahman", "family": "ibn Awf abdul Rahman", "initials": "A. a. R.", "capitalized_force": "ibn Awf Abdul Rahman"} -{"name": "ina binti navalamar", "given": "ina", "family": "binti navalamar", "initials": "i. n.", "capitalized": "Ina binti Navalamar"} +{"name": "ibn Ali Hasan", "family": "ibn Ali Hasan", "initials": "A. H.", "patronymic": "ibn Ali Hasan"} +{"name": "ibn Awf abdul Rahman", "family": "ibn Awf abdul Rahman", "initials": "A. a. R.", "capitalized_force": "ibn Awf Abdul Rahman", "patronymic": "ibn Awf abdul Rahman"} +{"name": "ina binti navalamar", "given": "ina", "family": "binti navalamar", "initials": "i. n.", "capitalized": "Ina binti Navalamar", "patronymic": "binti navalamar"} {"name": "j", "given": "j", "initials": "j.", "capitalized": "J"} {"name": "j nee Smith", "given": "j", "maiden": "Smith", "initials": "j.", "capitalized_force": "J (Smith)"} {"name": "j nee smith", "given": "j", "maiden": "smith", "initials": "j.", "capitalized": "J (Smith)"} @@ -3972,6 +3989,7 @@ {"name": "mwana Hamisi Salima Farida M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Hamisi", "middle": "Salima Farida", "family": "mwana", "suffix": "M.D. Ph.D.", "initials": "H. S. F. m.", "capitalized_force": "Mwana Hamisi Salima Farida M.D. Ph.D."} {"name": "nguyen van lac", "given": "nguyen", "family": "van", "suffix": "lac", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "n. v.", "capitalized": "Nguyen Van LAC"} {"name": "née Jones", "given": "née", "family": "Jones", "initials": "n. J.", "capitalized_force": "Née Jones"} +{"name": "oglu Ali", "given": "oglu", "family": "Ali", "initials": "o. A.", "capitalized_force": "Oglu Ali"} {"name": "onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "order": "FAMILY_FIRST", "given": "Sr.", "middle": "de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "family": "onorèvole", "initials": "S. L. G. A. B. T. C. o.", "capitalized_force": "Onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita"} {"name": "p. Petraitytė Gabrielė Aldona", "order": "FAMILY_FIRST", "given": "Petraitytė", "middle": "Gabrielė Aldona", "family": "p.", "initials": "P. G. A. p.", "capitalized_force": "P. Petraitytė Gabrielė Aldona"} {"name": "pani Alexandra Zuzana Macáková PhD", "given": "pani", "middle": "Alexandra Zuzana", "family": "Macáková", "suffix": "PhD", "initials": "p. A. Z. M.", "capitalized_force": "Pani Alexandra Zuzana MacÁková PhD"} @@ -4153,7 +4171,7 @@ {"name": "ʊŋono Alaaji Mustafa KALAM Alɛ", "given": "ʊŋono", "middle": "Alaaji Mustafa KALAM", "family": "Alɛ", "initials": "ʊ. A. M. K. A.", "capitalized_force": "Ʊŋono Alaaji Mustafa Kalam Alɛ"} {"name": "ʻIOLANI KAMAKA", "given": "ʻIOLANI", "family": "KAMAKA", "initials": "ʻ. K.", "capitalized": "ʻIolani Kamaka"} {"name": "ʿAbbās III.", "given": "ʿAbbās", "suffix": "III.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ʿ.", "generation": "III."} -{"name": "ʿAbd al-ʿAzīz Ibn Marwān", "given": "ʿAbd", "middle": "al-ʿAzīz", "family": "Ibn Marwān", "initials": "ʿ. a. M.", "capitalized_force": "ʿAbd al-ʿAzīz ibn Marwān"} +{"name": "ʿAbd al-ʿAzīz Ibn Marwān", "given": "ʿAbd", "middle": "al-ʿAzīz", "family": "Ibn Marwān", "initials": "ʿ. a. M.", "capitalized_force": "ʿAbd al-ʿAzīz ibn Marwān", "patronymic": "Ibn Marwān"} {"name": "ʿAbd-al-Qādir Badāʾūnī", "given": "ʿAbd-al-Qādir", "family": "Badāʾūnī", "initials": "ʿ. B.", "capitalized_force": "ʿAbd-Al-Qādir Badāʾūnī"} {"name": "ʿAbd-al-Ǧabbār Ibn-Aḥmad", "given": "ʿAbd-al-Ǧabbār", "family": "Ibn-Aḥmad", "initials": "ʿ. I.", "capitalized_force": "ʿAbd-Al-Ǧabbār ibn-Aḥmad"} {"name": "ʿAbdallāh Ibn-ʿAbdalqādir", "given": "ʿAbdallāh", "family": "Ibn-ʿAbdalqādir", "initials": "ʿ. I.", "capitalized_force": "ʿAbdallāh ibn-ʿAbdalqādir"} @@ -5241,7 +5259,7 @@ {"name": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي", "given": "محترم", "middle": "برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم", "family": "پي", "initials": "م. ب. و. ھ. ر. و. ا. پ."} {"name": "محمد البغدادي", "given": "محمد", "family": "البغدادي", "initials": "م. ا."} {"name": "محمد الزعبي", "given": "محمد", "family": "الزعبي", "initials": "م. ا."} -{"name": "محمد بن سلمان", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} +{"name": "محمد بن سلمان", "given": "محمد", "family": "بن سلمان", "initials": "م. س.", "patronymic": "بن سلمان"} {"name": "محمد خاتمی", "given": "محمد", "family": "خاتمی", "initials": "م. خ."} {"name": "محمد دهمان", "given": "محمد", "family": "دهمان", "initials": "م. د."} {"name": "محمد سليم الحلواني", "given": "محمد", "middle": "سليم", "family": "الحلواني", "initials": "م. س. ا."} @@ -6035,7 +6053,7 @@ {"name": "​Dr. John Smith", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "​王小明", "family": "王小明"} {"name": "‏John‏ Smith", "given": "John", "family": "Smith", "initials": "J. S."} -{"name": "‏محمد بن سلمان‏", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} +{"name": "‏محمد بن سلمان‏", "given": "محمد", "family": "بن سلمان", "initials": "م. س.", "patronymic": "بن سلمان"} {"name": "∫≜⩕ Smith 😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} {"name": "∫≜⩕ Smith😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} {"name": "〆木 ひろ", "given": "ひろ", "family": "〆木"} diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index e0dd1a5cd..c6270162e 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -9,6 +9,7 @@ (rules/R8) are recorded where the suffix holds a generation: `generation` then, and `credentials` beside it where the suffix holds both, so a suffix with no `generation` recorded is all credentials. +The `patronymic` view (rules/R9) is recorded where it is not empty. tests/v2/test_snapshot.py fails when the tree's readings stop matching it. A change that moves readings re-records, and the PR diff of snapshot.jsonl is the list of names it moved -- one line per name, so @@ -219,6 +220,8 @@ def row(name: str, order: str | None, v2: dict[str, Any] | None, out["generation"] = v2["_generation"] if v2["_credentials"]: out["credentials"] = v2["_credentials"] + if v2.get("_patronymic"): + out["patronymic"] = v2["_patronymic"] return out @@ -255,12 +258,15 @@ def alternatives(p: ParsedName) -> list[str]: #: The suffix's views a row records (rules/R8), in the order row() #: writes them. SUFFIX_VIEWS = ("generation", "credentials") +#: The patronymic view a row records where it is not empty (rules/R9). +PATRONYMIC_VIEW = ("patronymic",) #: The keys a row came to record after the snapshot existed, each with #: the change that added it: a snapshot recorded before it carries none #: of them, so a comparison against it leaves them out. RECORDED_LATER = ((CASE_VIEWS, "#826"), (("alternatives",), "#752 phase 2m"), - (SUFFIX_VIEWS, "3.0's 4b (#326)")) + (SUFFIX_VIEWS, "3.0's 4b (#326)"), + (PATRONYMIC_VIEW, "3.0's 4c (#754)")) #: The key of a snapshot's first line since #752 phase 2m, naming the #: `RECORDED_LATER` keys the tree that recorded it could record, so a @@ -282,7 +288,9 @@ def recorded_keys() -> tuple[str, ...]: return (CASE_VIEWS + (("alternatives",) if "alternative" in fields else ()) + (SUFFIX_VIEWS if hasattr(nameparser.ParsedName, "generation") - else ())) + else ()) + + (PATRONYMIC_VIEW + if hasattr(nameparser.ParsedName, "patronymic") else ())) def recorded(text: str) -> frozenset[str] | None: @@ -411,6 +419,7 @@ def build(policy: Policy) -> Parser: # none from a tree before 3.0, which `--base` reads through this v2["_generation"] = getattr(p, "generation", "") v2["_credentials"] = getattr(p, "credentials", "") + v2["_patronymic"] = getattr(p, "patronymic", "") v2.update(case_views(str(p), str(parsers[order].capitalized(p)), str(parsers[order].capitalized(p, force=True)))) rows.append(row(name, order, v2)) diff --git a/tools/differential/wikidata_agreement.json b/tools/differential/wikidata_agreement.json index 22b27c1aa..c93a264d5 100644 --- a/tools/differential/wikidata_agreement.json +++ b/tools/differential/wikidata_agreement.json @@ -10,6 +10,13 @@ }, "text": "Rafael Viciana Aráez" }, + "Q101644147/patronymic-ru/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Бычков, Пётр Николаевич" + }, "Q101688196/surname2-co/givenFirst": { "family": { "expected": "Gutiérrez Arango", @@ -21,6 +28,20 @@ }, "text": "Pompilio Gutiérrez Arango" }, + "Q102398858/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Митрофановна", + "got": "" + }, + "text": "Любовь Митрофановна Шуляк" + }, + "Q104148/patronymic-ru/sorting": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Смыслов, Василий Васильевич" + }, "Q104155694/surname2-es/givenFirst": { "family": { "expected": "García Rodrigo", @@ -65,6 +86,13 @@ }, "text": "Julio Roberto Gómez Esguerra" }, + "Q106089866/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Андреевич", + "got": "" + }, + "text": "Александр Андреевич Клюшников" + }, "Q106561769/surname2-co/givenFirst": { "family": { "expected": "Camacho Carrizosa", @@ -87,6 +115,13 @@ }, "text": "Libardo López Gómez" }, + "Q107182509/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Алексеевна", + "got": "" + }, + "text": "Дарья Алексеевна Навальная" + }, "Q107542992/surname2-mx/givenFirst": { "family": { "expected": "Covarrubias Garza", @@ -109,6 +144,13 @@ }, "text": "Gabriel Jaime Santamaría Montoya" }, + "Q108702510/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Александр Владимирович Кессених" + }, "Q109044495/surname2-co/givenFirst": { "family": { "expected": "González Arias", @@ -120,6 +162,76 @@ }, "text": "Jesús Ángel González Arias" }, + "Q109484835/patronymic-ru/sorting": { + "patronymic": { + "expected": "Терентьевич", + "got": "" + }, + "text": "Вишняков, Павел Терентьевич" + }, + "Q109485696/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Алексей Алексеевич Никитин" + }, + "Q109485696/patronymic-ru/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Никитин, Алексей Алексеевич" + }, + "Q109486438/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Владимир Михайлович Штейн" + }, + "Q109486503/patronymic-ri/sorting": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Повалишин, Владимир Михайлович" + }, + "Q109487083/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Афанасьевич", + "got": "" + }, + "text": "Владимир Афанасьевич Фатеев" + }, + "Q109487989/patronymic-su/sorting": { + "patronymic": { + "expected": "Фёдорович", + "got": "" + }, + "text": "Рязанов, Евгений Фёдорович" + }, + "Q109488265/patronymic-su/sorting": { + "patronymic": { + "expected": "Емельянович", + "got": "" + }, + "text": "Воронцов, Александр Емельянович" + }, + "Q109491939/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Семёнович", + "got": "" + }, + "text": "Алексей Семёнович Сечкин" + }, + "Q109621635/patronymic-su/sorting": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Шевчик, Дмитрий Владимирович" + }, "Q109738043/surname2-mx/givenFirst": { "family": { "expected": "López Díaz", @@ -131,6 +243,13 @@ }, "text": "Javier López Díaz" }, + "Q109831094/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Иосифович", + "got": "" + }, + "text": "Залман Иосифович Якуб" + }, "Q109853607/surname2-co/givenFirst": { "family": { "expected": "Sáenz Echeverría", @@ -153,6 +272,13 @@ }, "text": "Emilio Murillo Chapull" }, + "Q110165407/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Борис Алексеевич Зозуля" + }, "Q110463644/surname2-co/givenFirst": { "family": { "expected": "Uribe Cordovez", @@ -164,6 +290,20 @@ }, "text": "Carlos Uribe Cordovez" }, + "Q110721389/patronymic-ru/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Зорин, Михаил Петрович" + }, + "Q111080043/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Павел Алексеевич Рубцов" + }, "Q111234704/surname2-es/givenFirst": { "family": { "expected": "Díaz-Corralejo Fernández", @@ -217,6 +357,10 @@ "expected": "Sigurður Ólafsson", "got": "Sigurður" }, + "patronymic": { + "expected": "Ólafsson", + "got": "" + }, "text": "Sigurður Ólafsson" }, "Q112116260/patronymic-is/givenFirst": { @@ -228,6 +372,10 @@ "expected": "Bjarni Böðvarsson", "got": "Bjarni" }, + "patronymic": { + "expected": "Böðvarsson", + "got": "" + }, "text": "Bjarni Böðvarsson" }, "Q112116353/patronymic-is/givenFirst": { @@ -239,6 +387,10 @@ "expected": "Soffía Karlsdóttir", "got": "Soffía" }, + "patronymic": { + "expected": "Karlsdóttir", + "got": "" + }, "text": "Soffía Karlsdóttir" }, "Q112128748/patronymic-is/givenFirst": { @@ -250,6 +402,10 @@ "expected": "Ketill Jensson", "got": "Ketill" }, + "patronymic": { + "expected": "Jensson", + "got": "" + }, "text": "Ketill Jensson" }, "Q112130218/patronymic-is/givenFirst": { @@ -261,6 +417,10 @@ "expected": "Magnús Jónsson", "got": "Magnús" }, + "patronymic": { + "expected": "Jónsson", + "got": "" + }, "text": "Magnús Jónsson" }, "Q112130399/patronymic-is/givenFirst": { @@ -272,6 +432,10 @@ "expected": "Skapti Ólafsson", "got": "Skapti" }, + "patronymic": { + "expected": "Ólafsson", + "got": "" + }, "text": "Skapti Ólafsson" }, "Q112131085/patronymic-is/givenFirst": { @@ -283,6 +447,10 @@ "expected": "Jón Sigurðsson", "got": "Jón" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Jón Sigurðsson" }, "Q112131257/patronymic-is/givenFirst": { @@ -294,6 +462,10 @@ "expected": "Gerður Benediktsdóttir", "got": "Gerður" }, + "patronymic": { + "expected": "Benediktsdóttir", + "got": "" + }, "text": "Gerður Benediktsdóttir" }, "Q112135101/patronymic-is/givenFirst": { @@ -305,6 +477,10 @@ "expected": "Kristján Kristjánsson", "got": "Kristján" }, + "patronymic": { + "expected": "Kristjánsson", + "got": "" + }, "text": "Kristján Kristjánsson" }, "Q112135757/patronymic-is/givenFirst": { @@ -316,6 +492,10 @@ "expected": "Sigurdór Sigurdórsson", "got": "Sigurdór" }, + "patronymic": { + "expected": "Sigurdórsson", + "got": "" + }, "text": "Sigurdór Sigurdórsson" }, "Q112184094/patronymic-is/givenFirst": { @@ -327,6 +507,10 @@ "expected": "Rúnar Gunnarsson", "got": "Rúnar" }, + "patronymic": { + "expected": "Gunnarsson", + "got": "" + }, "text": "Rúnar Gunnarsson" }, "Q112184468/patronymic-is/givenFirst": { @@ -338,6 +522,10 @@ "expected": "Grettir Björnsson", "got": "Grettir" }, + "patronymic": { + "expected": "Björnsson", + "got": "" + }, "text": "Grettir Björnsson" }, "Q112208455/patronymic-is/givenFirst": { @@ -349,6 +537,10 @@ "expected": "Jóhann Konráðsson", "got": "Jóhann" }, + "patronymic": { + "expected": "Konráðsson", + "got": "" + }, "text": "Jóhann Konráðsson" }, "Q112340787/patronymic-is/givenFirst": { @@ -360,6 +552,10 @@ "expected": "Karl Einarsson", "got": "Karl" }, + "patronymic": { + "expected": "Einarsson", + "got": "" + }, "text": "Karl Einarsson" }, "Q112455664/patronymic-is/givenFirst": { @@ -371,8 +567,19 @@ "expected": "Ólafur Þ. Jónsson", "got": "Ólafur Þ." }, + "patronymic": { + "expected": "Jónsson", + "got": "" + }, "text": "Ólafur Þ. Jónsson" }, + "Q112506734/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Юрий Иванович Смирнов" + }, "Q112579257/patronymic-is/givenFirst": { "family": { "expected": "", @@ -382,6 +589,10 @@ "expected": "Eiður Gunnarsson", "got": "Eiður" }, + "patronymic": { + "expected": "Gunnarsson", + "got": "" + }, "text": "Eiður Gunnarsson" }, "Q112579515/patronymic-is/givenFirst": { @@ -393,8 +604,19 @@ "expected": "Guðmundur Óli Þorláksson", "got": "Guðmundur Óli" }, + "patronymic": { + "expected": "Þorláksson", + "got": "" + }, "text": "Guðmundur Óli Þorláksson" }, + "Q112666922/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Константин Михайлович Попов" + }, "Q112723929/surname2-es/givenFirst": { "family": { "expected": "Hernández Pla", @@ -406,6 +628,20 @@ }, "text": "Francisco Hernández Pla" }, + "Q112994217/patronymic-su/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Химченко, Виктор Иванович" + }, + "Q112994560/patronymic-ru/sorting": { + "patronymic": { + "expected": "Васильевна", + "got": "" + }, + "text": "Агеева, Валентина Васильевна" + }, "Q113001057/surname2-co/givenFirst": { "family": { "expected": "Simancas Torres", @@ -428,6 +664,13 @@ }, "text": "Fernando Rosas Solis" }, + "Q113469289/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Николай Николаевич Пикус" + }, "Q113514978/surname2-mx/givenFirst": { "family": { "expected": "Pérez Ximeno", @@ -461,6 +704,13 @@ }, "text": "Justo Novo Verdes" }, + "Q113568088/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александровна", + "got": "" + }, + "text": "Дарья Александровна Дугина" + }, "Q114799707/surname2-es/givenFirst": { "family": { "expected": "Gil Pérez", @@ -483,6 +733,20 @@ }, "text": "Ricardo Bodenlle Corral" }, + "Q115325153/patronymic-ru/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Демидов, Иван Алексеевич" + }, + "Q115495110/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Евгеньевич", + "got": "" + }, + "text": "Вадим Евгеньевич Бойко" + }, "Q115641830/surname2-co/givenFirst": { "family": { "expected": "Leyva Mazuera", @@ -494,6 +758,13 @@ }, "text": "Lisandro Leyva Mazuera" }, + "Q115734976/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Николаевна", + "got": "" + }, + "text": "Анна Николаевна Гладкова" + }, "Q116059/surname2-co/givenFirst": { "family": { "expected": "Lara Bonilla", @@ -505,6 +776,13 @@ }, "text": "Rodrigo Lara Bonilla" }, + "Q11667/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Вячеславовна", + "got": "" + }, + "text": "Елена Вячеславовна Дементьева" + }, "Q11686420/surname2-co/givenFirst": { "family": { "expected": "Cárdenas Arroyo", @@ -516,6 +794,20 @@ }, "text": "Juan Cárdenas Arroyo" }, + "Q116926100/patronymic-su/sorting": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Федоренко, Сергей Васильевич" + }, + "Q116926100/patronymic-su/surnameFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Федоренко Сергей Васильевич" + }, "Q11699292/surname2-es/givenFirst": { "family": { "expected": "Oriola Garriga", @@ -547,6 +839,10 @@ "expected": "Þorsteinn Sveinsson", "got": "Þorsteinn" }, + "patronymic": { + "expected": "Sveinsson", + "got": "" + }, "text": "Þorsteinn Sveinsson" }, "Q117459965/surname2-mx/givenFirst": { @@ -648,6 +944,34 @@ }, "text": "Hilda Luisa Valdemar Lima" }, + "Q12107891/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Феодосьевич", + "got": "" + }, + "text": "Павел Феодосьевич Кальницкий" + }, + "Q121322436/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Пётр Иванович Почивалов" + }, + "Q12141961/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Степанович", + "got": "" + }, + "text": "Михаил Степанович Полунин" + }, + "Q12149073/patronymic-ri/sorting": { + "patronymic": { + "expected": "Германович", + "got": "" + }, + "text": "Рубин, Павел Германович" + }, "Q122156972/surname2-mx/givenFirst": { "family": { "expected": "Dorado Baltazar", @@ -659,6 +983,13 @@ }, "text": "Emilia Dorado Baltazar" }, + "Q122199307/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Виктор Васильевич Ляпунов" + }, "Q123013116/surname2-co/givenFirst": { "family": { "expected": "Giraldo Builes", @@ -769,6 +1100,13 @@ }, "text": "Gabriel Latorre Jaramillo" }, + "Q124771331/patronymic-ru/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Богомолов, Владимир Николаевич" + }, "Q125025465/surname2-co/givenFirst": { "family": { "expected": "Garcés Córdoba", @@ -780,6 +1118,27 @@ }, "text": "Carlos Garcés Córdoba" }, + "Q125319126/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Пётр Петрович Буренин" + }, + "Q125322517/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Алексей Иванович Давыдов" + }, + "Q12558493/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Степановна", + "got": "" + }, + "text": "Пелагея Степановна Генералова" + }, "Q126949794/surname2-es/givenFirst": { "family": { "expected": "Sánchez Pesquera", @@ -791,6 +1150,20 @@ }, "text": "Miguel Sánchez Pesquera" }, + "Q126973227/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Андреевич", + "got": "" + }, + "text": "Николай Андреевич Смирнов" + }, + "Q12818587/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Порфирьевич", + "got": "" + }, + "text": "Николай Порфирьевич Архангельский" + }, "Q129570988/surname2-es/givenFirst": { "family": { "expected": "Irazabal Agirre", @@ -802,6 +1175,13 @@ }, "text": "Jon Irazabal Agirre" }, + "Q130223803/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Савельевич", + "got": "" + }, + "text": "Иван Савельевич Фатьянов" + }, "Q130383803/surname2-co/givenFirst": { "family": { "expected": "Rodríguez Agudelo", @@ -846,6 +1226,13 @@ }, "text": "Diego Alberto Tamayo Garcera" }, + "Q131120/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Сергеевна", + "got": "" + }, + "text": "Анна Сергеевна Курникова" + }, "Q131151443/surname2-mx/givenFirst": { "family": { "expected": "Gómez Campos", @@ -890,6 +1277,27 @@ }, "text": "Manuela Lema Villar" }, + "Q131630714/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Львовна", + "got": "" + }, + "text": "Алевтина Львовна Юрина" + }, + "Q131722489/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Георгиевич", + "got": "" + }, + "text": "Илья Георгиевич Коробков" + }, + "Q131722489/patronymic-ru/sorting": { + "patronymic": { + "expected": "Георгиевич", + "got": "" + }, + "text": "Коробков, Илья Георгиевич" + }, "Q131756403/surname2-mx/givenFirst": { "family": { "expected": "Palafox Herrán", @@ -901,6 +1309,13 @@ }, "text": "Elvira Palafox Herrán" }, + "Q131976309/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Ильич", + "got": "" + }, + "text": "Павел Ильич Таланкин" + }, "Q1330120/surname2-mx/givenFirst": { "family": { "expected": "Lozano Márquez", @@ -912,6 +1327,34 @@ }, "text": "Jorge Lozano Márquez" }, + "Q133208265/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Егор Николаевич Свиридов" + }, + "Q133299619/patronymic-ru/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Кузнецов, Евгений Николаевич" + }, + "Q133346769/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Борис Владимирович Полянский" + }, + "Q133346769/patronymic-ru/sorting": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Полянский, Борис Владимирович" + }, "Q13419335/patronymic-is/givenFirst": { "family": { "expected": "", @@ -921,6 +1364,10 @@ "expected": "Ragnar Bjarnason", "got": "Ragnar" }, + "patronymic": { + "expected": "Bjarnason", + "got": "" + }, "text": "Ragnar Bjarnason" }, "Q13423492/surname2-es/givenFirst": { @@ -934,6 +1381,13 @@ }, "text": "Silverio Cañada Acebal" }, + "Q134369454/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Владимир Сергеевич Шкляев" + }, "Q134423201/surname2-co/givenFirst": { "family": { "expected": "Baena Ortiz", @@ -978,6 +1432,13 @@ }, "text": "Martín Villazón Ochoa" }, + "Q134434774/patronymic-su/sorting": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Жилинский, Алексей Александрович" + }, "Q134479122/patronymic-is/givenFirst": { "family": { "expected": "", @@ -987,6 +1448,10 @@ "expected": "Haraldur Sigurðsson", "got": "Haraldur" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Haraldur Sigurðsson" }, "Q134544566/surname2-es/givenFirst": { @@ -1000,6 +1465,20 @@ }, "text": "Benigno Cunillera Torrent" }, + "Q134604187/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Емельянович", + "got": "" + }, + "text": "Владимир Емельянович Бойцов" + }, + "Q134604187/patronymic-ru/sorting": { + "patronymic": { + "expected": "Емельянович", + "got": "" + }, + "text": "Бойцов, Владимир Емельянович" + }, "Q1351501/surname2-co/givenFirst": { "family": { "expected": "Rodríguez Orejuela", @@ -1055,6 +1534,13 @@ }, "text": "Ever Peña Maldonado" }, + "Q136085718/patronymic-su/surnameFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Дмитриев Владимир Михайлович" + }, "Q136100927/surname2-es/givenFirst": { "family": { "expected": "Rodríguez Fernández", @@ -1075,6 +1561,10 @@ "expected": "Egill Eyjólfsson", "got": "Egill" }, + "patronymic": { + "expected": "Eyjólfsson", + "got": "" + }, "text": "Egill Eyjólfsson" }, "Q136342922/surname2-es/givenFirst": { @@ -1141,8 +1631,26 @@ "expected": "Teitur Þórðarson", "got": "Teitur" }, + "patronymic": { + "expected": "Þórðarson", + "got": "" + }, "text": "Teitur Þórðarson" }, + "Q1368874/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Денисович", + "got": "" + }, + "text": "Семён Денисович Игнатьев" + }, + "Q136927895/patronymic-is/givenFirst": { + "patronymic": { + "expected": "Eyjólfsson", + "got": "" + }, + "text": "Guðmundur Eyjólfsson Geirdal" + }, "Q137351899/surname2-es/givenFirst": { "family": { "expected": "Álvarez Iglesias", @@ -1154,6 +1662,34 @@ }, "text": "Luisa Álvarez Iglesias" }, + "Q137642434/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Николаевна", + "got": "" + }, + "text": "Татьяна Николаевна Семенова" + }, + "Q1376506/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Геннадий Иванович Воронов" + }, + "Q1376522/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Семёнович", + "got": "" + }, + "text": "Лев Семёнович Рубинштейн" + }, + "Q1376522/patronymic-ru/sorting": { + "patronymic": { + "expected": "Семёнович", + "got": "" + }, + "text": "Рубинштейн, Лев Семёнович" + }, "Q137963805/surname2-es/givenFirst": { "family": { "expected": "López González", @@ -1165,6 +1701,13 @@ }, "text": "Manuel López González" }, + "Q138647796/patronymic-su/sorting": { + "patronymic": { + "expected": "Григорьевич", + "got": "" + }, + "text": "Липицкий, Анатолий Григорьевич" + }, "Q138690631/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1174,6 +1717,10 @@ "expected": "Sveinn Ásgeirsson", "got": "Sveinn" }, + "patronymic": { + "expected": "Ásgeirsson", + "got": "" + }, "text": "Sveinn Ásgeirsson" }, "Q138703138/patronymic-is/givenFirst": { @@ -1185,6 +1732,10 @@ "expected": "Haukur Ágústsson", "got": "Haukur" }, + "patronymic": { + "expected": "Ágústsson", + "got": "" + }, "text": "Haukur Ágústsson" }, "Q139381793/surname2-co/givenFirst": { @@ -1229,6 +1780,10 @@ "expected": "Júlíus Agnarsson", "got": "Júlíus" }, + "patronymic": { + "expected": "Agnarsson", + "got": "" + }, "text": "Júlíus Agnarsson" }, "Q140266874/surname2-co/givenFirst": { @@ -1253,6 +1808,20 @@ }, "text": "Robiro Rojas Benavides" }, + "Q141108157/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Ивановна", + "got": "" + }, + "text": "Олимпиада Ивановна Шмидт" + }, + "Q141441031/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Гугович", + "got": "" + }, + "text": "Иван Гугович Закс" + }, "Q141607976/surname2-co/givenFirst": { "family": { "expected": "Santacruz Aguilar", @@ -1286,6 +1855,20 @@ }, "text": "Roque González Garza" }, + "Q15067243/patronymic-ru/sorting": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Иванов, Артемий Васильевич" + }, + "Q15069662/patronymic-ru/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Костин, Иван Алексеевич" + }, "Q15079846/surname2-mx/givenFirst": { "family": { "expected": "Ramos Millán", @@ -1297,6 +1880,13 @@ }, "text": "Gabriel Ramos Millán" }, + "Q15081249/patronymic-ru/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Сафонов, Эрнст Иванович" + }, "Q15109949/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1306,6 +1896,10 @@ "expected": "Þórunn Egilsdóttir", "got": "Þórunn" }, + "patronymic": { + "expected": "Egilsdóttir", + "got": "" + }, "text": "Þórunn Egilsdóttir" }, "Q1516038/surname2-mx/givenFirst": { @@ -1328,6 +1922,10 @@ "expected": "Guðrún Björnsdóttir", "got": "Guðrún" }, + "patronymic": { + "expected": "Björnsdóttir", + "got": "" + }, "text": "Guðrún Björnsdóttir" }, "Q152472/surname2-co/givenFirst": { @@ -1341,6 +1939,13 @@ }, "text": "Ingrid Betancourt Pulecio" }, + "Q15571/patronymic-ru/sorting": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Рыбаков, Ярослав Владимирович" + }, "Q15635899/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1350,6 +1955,10 @@ "expected": "Benedikt Erlingsson", "got": "Benedikt" }, + "patronymic": { + "expected": "Erlingsson", + "got": "" + }, "text": "Benedikt Erlingsson" }, "Q15711621/surname2-es/givenFirst": { @@ -1372,6 +1981,10 @@ "expected": "Sigurður Helgason", "got": "Sigurður" }, + "patronymic": { + "expected": "Helgason", + "got": "" + }, "text": "Sigurður Helgason" }, "Q16143814/surname2-co/givenFirst": { @@ -1427,6 +2040,10 @@ "expected": "Kristín Ólafsdóttir", "got": "Kristín" }, + "patronymic": { + "expected": "Ólafsdóttir", + "got": "" + }, "text": "Kristín Ólafsdóttir" }, "Q16420505/patronymic-is/givenFirst": { @@ -1438,6 +2055,10 @@ "expected": "Ólafur Ragnarsson", "got": "Ólafur" }, + "patronymic": { + "expected": "Ragnarsson", + "got": "" + }, "text": "Ólafur Ragnarsson" }, "Q16420913/patronymic-is/givenFirst": { @@ -1449,6 +2070,10 @@ "expected": "Loftur Guðmundsson", "got": "Loftur" }, + "patronymic": { + "expected": "Guðmundsson", + "got": "" + }, "text": "Loftur Guðmundsson" }, "Q16421121/patronymic-is/givenFirst": { @@ -1460,6 +2085,10 @@ "expected": "Bjarni Björnsson", "got": "Bjarni" }, + "patronymic": { + "expected": "Björnsson", + "got": "" + }, "text": "Bjarni Björnsson" }, "Q16421247/patronymic-is/givenFirst": { @@ -1471,6 +2100,10 @@ "expected": "Björgvin Gíslason", "got": "Björgvin" }, + "patronymic": { + "expected": "Gíslason", + "got": "" + }, "text": "Björgvin Gíslason" }, "Q16421751/patronymic-is/givenFirst": { @@ -1482,6 +2115,10 @@ "expected": "Þorsteinn Hannesson", "got": "Þorsteinn" }, + "patronymic": { + "expected": "Hannesson", + "got": "" + }, "text": "Þorsteinn Hannesson" }, "Q16424675/patronymic-is/givenFirst": { @@ -1493,6 +2130,10 @@ "expected": "Rúrik Haraldsson", "got": "Rúrik" }, + "patronymic": { + "expected": "Haraldsson", + "got": "" + }, "text": "Rúrik Haraldsson" }, "Q16425767/patronymic-is/givenFirst": { @@ -1504,6 +2145,10 @@ "expected": "Sigurður Björnsson", "got": "Sigurður" }, + "patronymic": { + "expected": "Björnsson", + "got": "" + }, "text": "Sigurður Björnsson" }, "Q16426100/patronymic-is/givenFirst": { @@ -1515,6 +2160,10 @@ "expected": "Guðmundur Jónsson", "got": "Guðmundur" }, + "patronymic": { + "expected": "Jónsson", + "got": "" + }, "text": "Guðmundur Jónsson" }, "Q16426181/patronymic-is/givenFirst": { @@ -1526,6 +2175,10 @@ "expected": "Guðrún Lárusdóttir", "got": "Guðrún" }, + "patronymic": { + "expected": "Lárusdóttir", + "got": "" + }, "text": "Guðrún Lárusdóttir" }, "Q16426629/patronymic-is/givenFirst": { @@ -1537,6 +2190,10 @@ "expected": "Hallbjörg Bjarnadóttir", "got": "Hallbjörg" }, + "patronymic": { + "expected": "Bjarnadóttir", + "got": "" + }, "text": "Hallbjörg Bjarnadóttir" }, "Q16429743/patronymic-is/givenFirst": { @@ -1548,6 +2205,10 @@ "expected": "Jónas Jónasson", "got": "Jónas" }, + "patronymic": { + "expected": "Jónasson", + "got": "" + }, "text": "Jónas Jónasson" }, "Q16429802/patronymic-is/givenFirst": { @@ -1559,6 +2220,10 @@ "expected": "Jónína Jónatansdóttir", "got": "Jónína" }, + "patronymic": { + "expected": "Jónatansdóttir", + "got": "" + }, "text": "Jónína Jónatansdóttir" }, "Q16490264/surname2-mx/givenFirst": { @@ -1583,6 +2248,13 @@ }, "text": "Margarita Peña Muñoz" }, + "Q16628165/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Семёнович", + "got": "" + }, + "text": "Александр Семёнович Будилович" + }, "Q1684202/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1592,8 +2264,19 @@ "expected": "Róbert Gunnarsson", "got": "Róbert" }, + "patronymic": { + "expected": "Gunnarsson", + "got": "" + }, "text": "Róbert Gunnarsson" }, + "Q1688645/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Францевич", + "got": "" + }, + "text": "Евгений Францевич Бауэр" + }, "Q16941958/surname2-co/givenFirst": { "family": { "expected": "Mora Toro", @@ -1647,6 +2330,10 @@ "expected": "Jón Steingrímsson", "got": "Jón" }, + "patronymic": { + "expected": "Steingrímsson", + "got": "" + }, "text": "Jón Steingrímsson" }, "Q17155819/surname2-es/givenFirst": { @@ -1660,6 +2347,13 @@ }, "text": "José Vilató Ruiz" }, + "Q1717/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Сергей Александрович Кирдяпкин" + }, "Q174100/surname2-mx/givenFirst": { "family": { "expected": "Bautista Rocha", @@ -1702,6 +2396,10 @@ "expected": "Selma Björnsdóttir", "got": "Selma" }, + "patronymic": { + "expected": "Björnsdóttir", + "got": "" + }, "text": "Selma Björnsdóttir" }, "Q1766810/patronymic-is/givenFirst": { @@ -1713,8 +2411,19 @@ "expected": "Sölvi Ottesen", "got": "Sölvi" }, + "patronymic": { + "expected": "Ottesen", + "got": "" + }, "text": "Sölvi Ottesen" }, + "Q177406/patronymic-ru/sorting": { + "patronymic": { + "expected": "Геннадьевна", + "got": "" + }, + "text": "Гойщик, Татьяна Геннадьевна" + }, "Q177712/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1724,6 +2433,10 @@ "expected": "Björn Þórðarson", "got": "Björn" }, + "patronymic": { + "expected": "Þórðarson", + "got": "" + }, "text": "Björn Þórðarson" }, "Q181383/patronymic-my/givenFirst": { @@ -1737,6 +2450,20 @@ }, "text": "Mahathir bin Mohamad" }, + "Q18145410/patronymic-ri/sorting": { + "patronymic": { + "expected": "Григорьевич", + "got": "" + }, + "text": "Александров, Николай Григорьевич" + }, + "Q18407860/patronymic-su/sorting": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Завадовский, Михаил Михайлович" + }, "Q1859374/surname2-mx/givenFirst": { "family": { "expected": "Fernández Castrillón", @@ -1748,6 +2475,13 @@ }, "text": "Manuel Fernández Castrillón" }, + "Q1860669/patronymic-su/sorting": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Саблин, Валерий Михайлович" + }, "Q1880038/surname2-mx/givenFirst": { "family": { "expected": "Yáñez Carrillo", @@ -1759,6 +2493,13 @@ }, "text": "José María Yáñez Carrillo" }, + "Q189665/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Иван Александрович Гончаров" + }, "Q189797/patronymic-is/givenFirst": { "family": { "expected": "", @@ -1768,8 +2509,19 @@ "expected": "Vigdís Finnbogadóttir", "got": "Vigdís" }, + "patronymic": { + "expected": "Finnbogadóttir", + "got": "" + }, "text": "Vigdís Finnbogadóttir" }, + "Q19317268/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Николай Васильевич Владыкин" + }, "Q19561528/surname2-co/givenFirst": { "family": { "expected": "Mesa Soto", @@ -1803,6 +2555,13 @@ }, "text": "Tomás González Carvajal" }, + "Q1968214/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Николай Иванович Хохлов" + }, "Q19684894/surname2-es/givenFirst": { "family": { "expected": "Ferrer Comas", @@ -1836,6 +2595,13 @@ }, "text": "Ramón Arellano Félix" }, + "Q1978432/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Анатолий Александрович Вассерман" + }, "Q1981295/surname2-co/givenFirst": { "family": { "expected": "Santos Calderón", @@ -1858,6 +2624,20 @@ }, "text": "Juan de Dios Sierra y Velásquez" }, + "Q19906568/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Ефремович", + "got": "" + }, + "text": "Георгий Ефремович Катанаев" + }, + "Q19907651/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Антон Дмитриевич Волошин" + }, "Q1993924/surname2-es/givenFirst": { "family": { "expected": "González Díaz", @@ -1900,8 +2680,33 @@ "expected": "Brynjólfur Bjarnason", "got": "Brynjólfur" }, + "patronymic": { + "expected": "Bjarnason", + "got": "" + }, "text": "Brynjólfur Bjarnason" }, + "Q2037108/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Эдуардович", + "got": "" + }, + "text": "Леонид Эдуардович Слуцкий" + }, + "Q2037108/patronymic-ru/sorting": { + "patronymic": { + "expected": "Эдуардович", + "got": "" + }, + "text": "Слуцкий, Леонид Эдуардович" + }, + "Q20483301/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Егор Николаевич Булаткин" + }, "Q20534308/surname2-es/givenFirst": { "family": { "expected": "Banet Fontenla", @@ -1955,8 +2760,19 @@ "expected": "Lárus Pálsson", "got": "Lárus" }, + "patronymic": { + "expected": "Pálsson", + "got": "" + }, "text": "Lárus Pálsson" }, + "Q205981/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Александр Александрович Фридман" + }, "Q20743706/surname2-es/givenFirst": { "family": { "expected": "Ortega Martín", @@ -1990,6 +2806,34 @@ }, "text": "Enrique Gorostieta Velarde" }, + "Q21099074/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Виктор Михайлович Иванцов" + }, + "Q21101505/patronymic-ri/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Козловский, Фёдор Алексеевич" + }, + "Q21106710/patronymic-ru/sorting": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Медведев, Николай Дмитриевич" + }, + "Q21517195/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Евгеньевич", + "got": "" + }, + "text": "Николай Евгеньевич Кабанов" + }, "Q2259207/surname2-mx/givenFirst": { "family": { "expected": "Guzmán Rodríguez", @@ -2012,6 +2856,13 @@ }, "text": "Ángel Llanos González" }, + "Q232995/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Сергіївна", + "got": "" + }, + "text": "Оксана Сергіївна Баюл" + }, "Q235562/surname2-co/givenFirst": { "family": { "expected": "Duque Mariño", @@ -2023,6 +2874,13 @@ }, "text": "Mariana Duque Mariño" }, + "Q2370506/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Игоревна", + "got": "" + }, + "text": "Ольга Игоревна Кабо" + }, "Q23753778/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2032,8 +2890,26 @@ "expected": "Lilja Alfreðsdóttir", "got": "Lilja" }, + "patronymic": { + "expected": "Alfreðsdóttir", + "got": "" + }, "text": "Lilja Alfreðsdóttir" }, + "Q2376200/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Валерьевич", + "got": "" + }, + "text": "Алексей Валерьевич Серебряков" + }, + "Q2386694/patronymic-ri/sorting": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Попов, Андрей Александрович" + }, "Q23927812/surname2-mx/givenFirst": { "family": { "expected": "Zupo Gutiérrez", @@ -2098,6 +2974,10 @@ "expected": "Kristján Örn Sigurðsson", "got": "Kristján Örn" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Kristján Örn Sigurðsson" }, "Q24494577/patronymic-is/givenFirst": { @@ -2109,6 +2989,10 @@ "expected": "Halla Tómasdóttir", "got": "Halla" }, + "patronymic": { + "expected": "Tómasdóttir", + "got": "" + }, "text": "Halla Tómasdóttir" }, "Q2463622/surname2-es/givenFirst": { @@ -2122,6 +3006,13 @@ }, "text": "Manuel Blanco Romasanta" }, + "Q2472305/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Асаф Михайлович Мессерер" + }, "Q24961757/surname2-mx/givenFirst": { "family": { "expected": "García Ocejo", @@ -2153,6 +3044,10 @@ "expected": "Árni Þorláksson", "got": "Árni" }, + "patronymic": { + "expected": "Þorláksson", + "got": "" + }, "text": "Árni Þorláksson" }, "Q252586/patronymic-is/givenFirst": { @@ -2164,6 +3059,10 @@ "expected": "Ásta Ragnheiður Jóhannesdóttir", "got": "Ásta Ragnheiður" }, + "patronymic": { + "expected": "Jóhannesdóttir", + "got": "" + }, "text": "Ásta Ragnheiður Jóhannesdóttir" }, "Q25402592/surname2-co/givenFirst": { @@ -2177,6 +3076,27 @@ }, "text": "Francisco Posada Díaz" }, + "Q25598013/patronymic-su/surnameFirst": { + "patronymic": { + "expected": "Львович", + "got": "" + }, + "text": "Френкель Георгий Львович" + }, + "Q25796385/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Михайловна", + "got": "" + }, + "text": "Нина Михайловна Литвинова" + }, + "Q25895/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Валерьевна", + "got": "" + }, + "text": "Мария Валерьевна Мазина" + }, "Q262253/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2186,6 +3106,10 @@ "expected": "Yrsa Sigurðardóttir", "got": "Yrsa" }, + "patronymic": { + "expected": "Sigurðardóttir", + "got": "" + }, "text": "Yrsa Sigurðardóttir" }, "Q2623568/surname2-mx/givenFirst": { @@ -2219,6 +3143,10 @@ "expected": "Hera Hjartardóttir", "got": "Hera" }, + "patronymic": { + "expected": "Hjartardóttir", + "got": "" + }, "text": "Hera Hjartardóttir" }, "Q264516/patronymic-my/givenFirst": { @@ -2230,8 +3158,26 @@ "expected": "Abdul Razak Hussein", "got": "Abdul Razak" }, + "patronymic": { + "expected": "Hussein", + "got": "" + }, "text": "Abdul Razak Hussein" }, + "Q265610/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Трофимовна", + "got": "" + }, + "text": "Жанна Трофимовна Прохоренко" + }, + "Q26736115/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Александр Иванович Булгаков" + }, "Q2718231/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2241,6 +3187,10 @@ "expected": "Kári Árnason", "got": "Kári" }, + "patronymic": { + "expected": "Árnason", + "got": "" + }, "text": "Kári Árnason" }, "Q28028537/surname2-mx/givenFirst": { @@ -2265,6 +3215,27 @@ }, "text": "Rafael Cepeda Torres" }, + "Q2832542/patronymic-ru/sorting": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Грипич, Александр Сергеевич" + }, + "Q2833996/patronymic-ru/sorting": { + "patronymic": { + "expected": "Павлович", + "got": "" + }, + "text": "Расницын, Александр Павлович" + }, + "Q2834049/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Александр Николаевич Степанов" + }, "Q28343272/surname2-co/givenFirst": { "family": { "expected": "Martínez Arango", @@ -2298,6 +3269,20 @@ }, "text": "Arturo Acevedo Vallarino" }, + "Q2846841/patronymic-ru/sorting": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Старовойтов, Андрей Васильевич" + }, + "Q28497247/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Валентин Васильевич Васенков" + }, "Q28501494/surname2-mx/givenFirst": { "family": { "expected": "Mazón Pineda", @@ -2320,6 +3305,13 @@ }, "text": "Carlos Coello de Portugal y Pacheco" }, + "Q28760444/patronymic-ru/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Богданов, Владимир Алексеевич" + }, "Q28792898/surname2-mx/givenFirst": { "family": { "expected": "Lanz Duret", @@ -2362,6 +3354,10 @@ "expected": "Ívar Ingimarsson", "got": "Ívar" }, + "patronymic": { + "expected": "Ingimarsson", + "got": "" + }, "text": "Ívar Ingimarsson" }, "Q294666/patronymic-is/givenFirst": { @@ -2373,8 +3369,19 @@ "expected": "Ögmundur Jónasson", "got": "Ögmundur" }, + "patronymic": { + "expected": "Jónasson", + "got": "" + }, "text": "Ögmundur Jónasson" }, + "Q29649854/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Максимович", + "got": "" + }, + "text": "Борис Максимович Келлер" + }, "Q29649988/surname2-co/givenFirst": { "family": { "expected": "Villada Hoyos", @@ -2386,6 +3393,13 @@ }, "text": "Salomón Villada Hoyos" }, + "Q297736/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Евгений Александрович Евтушенко" + }, "Q29860266/surname2-mx/givenFirst": { "family": { "expected": "Colomo Castro", @@ -2406,6 +3420,10 @@ "expected": "Anwar Ibrahim", "got": "Anwar" }, + "patronymic": { + "expected": "Ibrahim", + "got": "" + }, "text": "Anwar Ibrahim" }, "Q29959449/surname2-mx/givenFirst": { @@ -2419,6 +3437,13 @@ }, "text": "Octavio Corral Romero" }, + "Q30079822/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Алексей Иванович Савельев" + }, "Q3031692/surname2-mx/givenFirst": { "family": { "expected": "Gómez Bolaños", @@ -2452,6 +3477,20 @@ }, "text": "Francisco Freire Barreiro" }, + "Q310320/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Евграфович", + "got": "" + }, + "text": "Владимир Евграфович Татлин" + }, + "Q311033/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Степанович", + "got": "" + }, + "text": "Николай Степанович Гумилёв" + }, "Q31186764/patronymic-my/givenFirst": { "family": { "expected": "", @@ -2461,6 +3500,10 @@ "expected": "Abdul Aziz Yassin", "got": "Abdul Aziz" }, + "patronymic": { + "expected": "Yassin", + "got": "" + }, "text": "Abdul Aziz Yassin" }, "Q3124411/surname2-mx/givenFirst": { @@ -2483,8 +3526,26 @@ "expected": "Daði Freyr Pétursson", "got": "Daði Freyr" }, + "patronymic": { + "expected": "Pétursson", + "got": "" + }, "text": "Daði Freyr Pétursson" }, + "Q32717/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Валерьевич", + "got": "" + }, + "text": "Владимир Валерьевич Буре" + }, + "Q32827/patronymic-ru/sorting": { + "patronymic": { + "expected": "Борисович", + "got": "" + }, + "text": "Колкутин, Андрей Борисович" + }, "Q3294918/surname2-mx/givenFirst": { "family": { "expected": "García Cantú", @@ -2507,6 +3568,13 @@ }, "text": "Xosé María Álvarez Blázquez" }, + "Q34453/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Борис Николаевич Ельцин" + }, "Q3480844/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2516,6 +3584,10 @@ "expected": "Edda Garðarsdóttir", "got": "Edda" }, + "patronymic": { + "expected": "Garðarsdóttir", + "got": "" + }, "text": "Edda Garðarsdóttir" }, "Q3481235/patronymic-is/givenFirst": { @@ -2527,8 +3599,26 @@ "expected": "Sara Björk Gunnarsdóttir", "got": "Sara Björk" }, + "patronymic": { + "expected": "Gunnarsdóttir", + "got": "" + }, "text": "Sara Björk Gunnarsdóttir" }, + "Q351080/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Вадим Владимирович Наумов" + }, + "Q351080/patronymic-ru/sorting": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Наумов, Вадим Владимирович" + }, "Q35160252/surname2-mx/givenFirst": { "family": { "expected": "Nava García", @@ -2540,6 +3630,20 @@ }, "text": "Martha Nava García" }, + "Q351886/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Романович", + "got": "" + }, + "text": "Евгений Романович Устюгов" + }, + "Q35448/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Пётр Николаевич Краснов" + }, "Q3548080/surname2-mx/givenFirst": { "family": { "expected": "Schreiber Castañón", @@ -2560,6 +3664,10 @@ "expected": "Steinunn Sigurðardóttir", "got": "Steinunn" }, + "patronymic": { + "expected": "Sigurðardóttir", + "got": "" + }, "text": "Steinunn Sigurðardóttir" }, "Q35689580/surname2-es/givenFirst": { @@ -2573,6 +3681,13 @@ }, "text": "José Gómez-Mena Vila" }, + "Q360507/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Михаил Михайлович Зощенко" + }, "Q362531/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2582,8 +3697,33 @@ "expected": "Sigurjón Birgir Sigurðsson", "got": "Sigurjón Birgir" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Sigurjón Birgir Sigurðsson" }, + "Q36499621/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Алексей Васильевич Богдан" + }, + "Q367029/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Ильич", + "got": "" + }, + "text": "Юрий Ильич Скуратов" + }, + "Q3791296/patronymic-ru/sorting": { + "patronymic": { + "expected": "Юрьевич", + "got": "" + }, + "text": "Хржановский, Андрей Юрьевич" + }, "Q3808593/surname2-mx/givenFirst": { "family": { "expected": "García Icazbalceta", @@ -2604,6 +3744,10 @@ "expected": "Matthías Jochumsson", "got": "Matthías" }, + "patronymic": { + "expected": "Jochumsson", + "got": "" + }, "text": "Matthías Jochumsson" }, "Q3810410/surname2-mx/givenFirst": { @@ -2626,6 +3770,10 @@ "expected": "Gunnar Gunnarsson", "got": "Gunnar" }, + "patronymic": { + "expected": "Gunnarsson", + "got": "" + }, "text": "Gunnar Gunnarsson" }, "Q3816879/surname2-es/givenFirst": { @@ -2639,6 +3787,27 @@ }, "text": "María de la O Lejárraga y García" }, + "Q3856322/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Михаил Дмитриевич Чулков" + }, + "Q38668675/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Сергеевна", + "got": "" + }, + "text": "Ксения Сергеевна Александрова" + }, + "Q3920829/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Леонидович", + "got": "" + }, + "text": "Александр Леонидович Дворкин" + }, "Q4016455/surname2-co/givenFirst": { "family": { "expected": "Lemos Simmonds", @@ -2650,6 +3819,146 @@ }, "text": "Carlos Lemos Simmonds" }, + "Q4054347/patronymic-ru/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Абатуров, Константин Иванович" + }, + "Q4057185/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Хаимович", + "got": "" + }, + "text": "Самуил Хаимович Агурский" + }, + "Q4061598/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Александр Иванович Алексеев" + }, + "Q4061685/patronymic-ru/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Алексеев, Михаил Николаевич" + }, + "Q4093129/patronymic-su/sorting": { + "patronymic": { + "expected": "Автономович", + "got": "" + }, + "text": "Бондарев, Николай Автономович" + }, + "Q4095525/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Николай Александрович Боярский" + }, + "Q4100955/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Пётр Васильевич Быков" + }, + "Q4102225/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Николай Петрович Вагнер" + }, + "Q4102225/patronymic-ri/surnameFirst": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Вагнер Николай Петрович" + }, + "Q4123342/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Павел Михайлович Волков" + }, + "Q4125181/patronymic-su/sorting": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Воробьёв, Константин Дмитриевич" + }, + "Q4127201/patronymic-ru/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Вронский, Юрий Петрович" + }, + "Q4140268/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Виктор Владимирович Глухов" + }, + "Q4140733/patronymic-su/sorting": { + "patronymic": { + "expected": "Тимофеевна", + "got": "" + }, + "text": "Гнилицкая, Нина Тимофеевна" + }, + "Q4149792/patronymic-ru/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Гриценко, Анатолий Иванович" + }, + "Q4162468/patronymic-ri/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Дитятин, Иван Иванович" + }, + "Q4172503/patronymic-su/sorting": { + "patronymic": { + "expected": "Семёнович", + "got": "" + }, + "text": "Дятлов, Владимир Семёнович" + }, + "Q4174932/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Борисович", + "got": "" + }, + "text": "Алексей Борисович Елисеев" + }, + "Q4195761/patronymic-ri/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Иваницкий, Николай Иванович" + }, + "Q4197788/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Вячеславович", + "got": "" + }, + "text": "Геннадий Вячеславович Игумнов" + }, "Q4198159/patronymic-my/givenFirst": { "family": { "expected": "", @@ -2659,6 +3968,10 @@ "expected": "Ismail Abdul Rahman", "got": "Ismail Abdul" }, + "patronymic": { + "expected": "Abdul Rahman", + "got": "" + }, "text": "Ismail Abdul Rahman" }, "Q41991777/surname2-es/givenFirst": { @@ -2672,6 +3985,27 @@ }, "text": "Juan Fernández Bazán" }, + "Q4213414/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Владимир Владимирович Кара-Мурза" + }, + "Q4222186/patronymic-ri/sorting": { + "patronymic": { + "expected": "Степанович", + "got": "" + }, + "text": "Кислов, Александр Степанович" + }, + "Q4234503/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Олександрович", + "got": "" + }, + "text": "Дмитро Олександрович Корчинський" + }, "Q42455/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2681,8 +4015,19 @@ "expected": "Björk Guðmundsdóttir", "got": "Björk" }, + "patronymic": { + "expected": "Guðmundsdóttir", + "got": "" + }, "text": "Björk Guðmundsdóttir" }, + "Q4256204/patronymic-ru/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Левандовский, Анатолий Петрович" + }, "Q425825/surname2-co/givenFirst": { "family": { "expected": "Calvo Ospina", @@ -2694,6 +4039,20 @@ }, "text": "Hernando Calvo Ospina" }, + "Q4271524/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Яковлевич", + "got": "" + }, + "text": "Павел Яковлевич Любимов" + }, + "Q4272531/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Игоревна", + "got": "" + }, + "text": "Елена Игоревна Лядова" + }, "Q4283792/surname2-mx/givenFirst": { "family": { "expected": "Tello Macías", @@ -2705,6 +4064,13 @@ }, "text": "Manuel Tello Macías" }, + "Q4284526/patronymic-su/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Матвеев, Герман Иванович" + }, "Q42907085/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2714,8 +4080,110 @@ "expected": "Óskar Halldórsson", "got": "Óskar" }, + "patronymic": { + "expected": "Halldórsson", + "got": "" + }, "text": "Óskar Halldórsson" }, + "Q429915/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Александр Петрович Сумароков" + }, + "Q4299705/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Александр Алексеевич Моисеев" + }, + "Q4299705/patronymic-ru/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Моисеев, Александр Алексеевич" + }, + "Q4313956/patronymic-ri/sorting": { + "patronymic": { + "expected": "Викторович", + "got": "" + }, + "text": "Насонов, Николай Викторович" + }, + "Q4314363/patronymic-su/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Наумов, Михаил Иванович" + }, + "Q4334317/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Валерий Сергеевич Ольховский" + }, + "Q4334317/patronymic-ru/sorting": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Ольховский, Валерий Сергеевич" + }, + "Q4337703/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Викторович", + "got": "" + }, + "text": "Евгений Викторович Осин" + }, + "Q43425443/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Виктор Иванович Туровцев" + }, + "Q4343990/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Вячеславович", + "got": "" + }, + "text": "Алексей Вячеславович Панин" + }, + "Q4343990/patronymic-ru/sorting": { + "patronymic": { + "expected": "Вячеславович", + "got": "" + }, + "text": "Панин, Алексей Вячеславович" + }, + "Q4372428/patronymic-ri/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Попов, Василий Петрович" + }, + "Q4373277/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Володимирович", + "got": "" + }, + "text": "Андрій Володимирович Портнов" + }, + "Q4377304/patronymic-ri/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Преображенский, Василий Алексеевич" + }, "Q437932/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2725,6 +4193,10 @@ "expected": "Siv Friðleifsdóttir", "got": "Siv" }, + "patronymic": { + "expected": "Friðleifsdóttir", + "got": "" + }, "text": "Siv Friðleifsdóttir" }, "Q438008/patronymic-is/givenFirst": { @@ -2736,6 +4208,10 @@ "expected": "Guðrún Eva Mínervudóttir", "got": "Guðrún Eva" }, + "patronymic": { + "expected": "Mínervudóttir", + "got": "" + }, "text": "Guðrún Eva Mínervudóttir" }, "Q438031/patronymic-is/givenFirst": { @@ -2747,8 +4223,68 @@ "expected": "Guðbjörg Gunnarsdóttir", "got": "Guðbjörg" }, + "patronymic": { + "expected": "Gunnarsdóttir", + "got": "" + }, "text": "Guðbjörg Gunnarsdóttir" }, + "Q4387043/patronymic-ri/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Рагозин, Евгений Иванович" + }, + "Q438839/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Гельевич", + "got": "" + }, + "text": "Александр Гельевич Дугин" + }, + "Q4388811/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Георгиевич", + "got": "" + }, + "text": "Юрий Георгиевич Разумовский" + }, + "Q4388811/patronymic-ru/sorting": { + "patronymic": { + "expected": "Георгиевич", + "got": "" + }, + "text": "Разумовский, Юрий Георгиевич" + }, + "Q4396579/patronymic-su/sorting": { + "patronymic": { + "expected": "Константинович", + "got": "" + }, + "text": "Розенфельд, Михаил Константинович" + }, + "Q4401515/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Андреевич", + "got": "" + }, + "text": "Николай Андреевич Рыбин" + }, + "Q4403531/patronymic-ri/sorting": { + "patronymic": { + "expected": "Алексеевич", + "got": "" + }, + "text": "Саблин, Александр Алексеевич" + }, + "Q4428563/patronymic-ri/sorting": { + "patronymic": { + "expected": "Мартынович", + "got": "" + }, + "text": "Сольский, Семён Мартынович" + }, "Q4429030/surname2-es/givenFirst": { "family": { "expected": "Soriano Fuertes", @@ -2760,6 +4296,41 @@ }, "text": "Indalecio Soriano Fuertes" }, + "Q4448299/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Федот Васильевич Сычков" + }, + "Q4448725/patronymic-su/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Сёмин, Виталий Николаевич" + }, + "Q4459965/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Олегович", + "got": "" + }, + "text": "Пётр Олегович Толстой" + }, + "Q4477826/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Владимир Александрович Усов" + }, + "Q4481541/patronymic-su/sorting": { + "patronymic": { + "expected": "Ивановна", + "got": "" + }, + "text": "Фаусек, Юлия Ивановна" + }, "Q448642/surname2-mx/givenFirst": { "family": { "expected": "Paredes Rangel", @@ -2771,6 +4342,69 @@ }, "text": "Beatriz Elena Paredes Rangel" }, + "Q4501355/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Борисович", + "got": "" + }, + "text": "Юрий Борисович Хржановский" + }, + "Q4501920/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Никита Сергеевич Хрущёв" + }, + "Q4511967/patronymic-su/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Червенков, Николай Николаевич" + }, + "Q4512838/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Александр Иванович Черепанов" + }, + "Q4512838/patronymic-su/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Черепанов, Александр Иванович" + }, + "Q4514365/patronymic-su/sorting": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Чернышёв, Пётр Сергеевич" + }, + "Q4523811/patronymic-ri/sorting": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Шиллер, Николай Николаевич" + }, + "Q4531782/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Николай Александрович Энгельгардт" + }, + "Q45527009/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Петровна", + "got": "" + }, + "text": "Ульяна Петровна Буянова" + }, "Q455683/surname2-mx/givenFirst": { "family": { "expected": "Ortiz Rubio", @@ -2793,6 +4427,13 @@ }, "text": "Joaquín Guzmán Loera" }, + "Q460023/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Юрий Петрович Любимов" + }, "Q462196/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2802,6 +4443,10 @@ "expected": "Vilborg Davíðsdóttir", "got": "Vilborg" }, + "patronymic": { + "expected": "Davíðsdóttir", + "got": "" + }, "text": "Vilborg Davíðsdóttir" }, "Q467464/patronymic-is/givenFirst": { @@ -2813,6 +4458,10 @@ "expected": "Katrín Jakobsdóttir", "got": "Katrín" }, + "patronymic": { + "expected": "Jakobsdóttir", + "got": "" + }, "text": "Katrín Jakobsdóttir" }, "Q47087536/surname2-es/givenFirst": { @@ -2826,6 +4475,20 @@ }, "text": "José Mataix Monllor" }, + "Q4715527/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Вячеславович", + "got": "" + }, + "text": "Александр Вячеславович Жиров" + }, + "Q4715527/patronymic-ru/sorting": { + "patronymic": { + "expected": "Вячеславович", + "got": "" + }, + "text": "Жиров, Александр Вячеславович" + }, "Q4739054/surname2-mx/givenFirst": { "family": { "expected": "Aguirre Santiago", @@ -2848,6 +4511,20 @@ }, "text": "Eduardo Pallares Portillo" }, + "Q476758/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Андреевич", + "got": "" + }, + "text": "Иван Андреевич Созонов" + }, + "Q47711697/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Леонид Иванович Иванов" + }, "Q4776859/surname2-co/givenFirst": { "family": { "expected": "París Sanz de Santamaría", @@ -2892,6 +4569,20 @@ }, "text": "Arcadio Poveda Ricalde" }, + "Q48145/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Афанасьевич", + "got": "" + }, + "text": "Кирилл Афанасьевич Мерецков" + }, + "Q48355/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Игорь Александрович Нетто" + }, "Q48554/patronymic-is/givenFirst": { "family": { "expected": "", @@ -2901,8 +4592,26 @@ "expected": "Aron Jóhannsson", "got": "Aron" }, + "patronymic": { + "expected": "Jóhannsson", + "got": "" + }, "text": "Aron Jóhannsson" }, + "Q487927/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Анатолий Николаевич Алябьев" + }, + "Q502651/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Васильевич", + "got": "" + }, + "text": "Андрей Васильевич Мягков" + }, "Q5041784/surname2-co/givenFirst": { "family": { "expected": "Ardila Lülle", @@ -2945,6 +4654,10 @@ "expected": "Steingrímur Jóhann Sigfússon", "got": "Steingrímur Jóhann" }, + "patronymic": { + "expected": "Sigfússon", + "got": "" + }, "text": "Steingrímur Jóhann Sigfússon" }, "Q5125955/surname2-mx/givenFirst": { @@ -2969,6 +4682,27 @@ }, "text": "Sócrates Gómez Pérez" }, + "Q516275/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Андреевна", + "got": "" + }, + "text": "Евдокия Андреевна Никулина" + }, + "Q516275/patronymic-ru/sorting": { + "patronymic": { + "expected": "Андреевна", + "got": "" + }, + "text": "Никулина, Евдокия Андреевна" + }, + "Q51668995/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Павел Владимирович Сорокин" + }, "Q520713/surname2-es/givenFirst": { "family": { "expected": "Torres García", @@ -3000,6 +4734,10 @@ "expected": "Margrét Lára Viðarsdóttir", "got": "Margrét Lára" }, + "patronymic": { + "expected": "Viðarsdóttir", + "got": "" + }, "text": "Margrét Lára Viðarsdóttir" }, "Q539421/patronymic-is/givenFirst": { @@ -3011,6 +4749,10 @@ "expected": "Katrín Júlíusdóttir", "got": "Katrín" }, + "patronymic": { + "expected": "Júlíusdóttir", + "got": "" + }, "text": "Katrín Júlíusdóttir" }, "Q5399460/surname2-co/givenFirst": { @@ -3033,6 +4775,10 @@ "expected": "Guðrún Helgadóttir", "got": "Guðrún" }, + "patronymic": { + "expected": "Helgadóttir", + "got": "" + }, "text": "Guðrún Helgadóttir" }, "Q5412830/surname2-es/givenFirst": { @@ -3046,6 +4792,27 @@ }, "text": "Francisco González Macías" }, + "Q548164/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Юрьевич", + "got": "" + }, + "text": "Владислав Юрьевич Сурков" + }, + "Q54964679/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Фёдорович", + "got": "" + }, + "text": "Валерий Фёдорович Залужный" + }, + "Q55208/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Сергеевич", + "got": "" + }, + "text": "Андрей Сергеевич Кончаловский" + }, "Q55229105/surname2-es/givenFirst": { "family": { "expected": "García Ulecia", @@ -3143,6 +4910,10 @@ "expected": "Jón Þorláksson", "got": "Jón" }, + "patronymic": { + "expected": "Þorláksson", + "got": "" + }, "text": "Jón Þorláksson" }, "Q5667134/surname2-co/givenFirst": { @@ -3167,6 +4938,13 @@ }, "text": "Eduardo Zalamea Borda" }, + "Q56822727/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Виссарион Михайлович Павлов" + }, "Q5696422/surname2-co/givenFirst": { "family": { "expected": "Osorio Lizarazo", @@ -3242,6 +5020,10 @@ "expected": "Jóhanna Sigurðardóttir", "got": "Jóhanna" }, + "patronymic": { + "expected": "Sigurðardóttir", + "got": "" + }, "text": "Jóhanna Sigurðardóttir" }, "Q5806647/surname2-co/givenFirst": { @@ -3275,6 +5057,10 @@ "expected": "Jón Páll Sigmarsson", "got": "Jón Páll" }, + "patronymic": { + "expected": "Sigmarsson", + "got": "" + }, "text": "Jón Páll Sigmarsson" }, "Q5858108/surname2-mx/givenFirst": { @@ -3332,6 +5118,13 @@ }, "text": "Francisco Martínez de Cenicero" }, + "Q58709/patronymic-ru/sorting": { + "patronymic": { + "expected": "Васильевна", + "got": "" + }, + "text": "Журавлёва, Людмила Васильевна" + }, "Q5888901/surname2-mx/givenFirst": { "family": { "expected": "Rousset Banda", @@ -3363,6 +5156,10 @@ "expected": "Hera Hilmarsdóttir", "got": "Hera" }, + "patronymic": { + "expected": "Hilmarsdóttir", + "got": "" + }, "text": "Hera Hilmarsdóttir" }, "Q59105/patronymic-is/givenFirst": { @@ -3374,6 +5171,10 @@ "expected": "Gylfi Sigurðsson", "got": "Gylfi" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Gylfi Sigurðsson" }, "Q5924935/surname2-es/givenFirst": { @@ -3473,8 +5274,26 @@ "expected": "Hansína Regína Björnsdóttir", "got": "Hansína Regína" }, + "patronymic": { + "expected": "Björnsdóttir", + "got": "" + }, "text": "Hansína Regína Björnsdóttir" }, + "Q59938868/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Емельянович", + "got": "" + }, + "text": "Александр Емельянович Хмель" + }, + "Q600678/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Николаевич", + "got": "" + }, + "text": "Алексей Николаевич Плещеев" + }, "Q6055948/surname2-co/givenFirst": { "family": { "expected": "Riveros Reina", @@ -3508,6 +5327,13 @@ }, "text": "Pedro Galindo Galarza" }, + "Q60695279/patronymic-su/sorting": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Корягин, Дмитрий Александрович" + }, "Q6069555/surname2-co/givenFirst": { "family": { "expected": "Morales Pino", @@ -3585,6 +5411,13 @@ }, "text": "Víctor Luna Gómez" }, + "Q61668399/patronymic-ru/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Евтеев, Николай Петрович" + }, "Q61697989/surname2-mx/givenFirst": { "family": { "expected": "Azcárraga Madero", @@ -3596,6 +5429,13 @@ }, "text": "Rogerio Azcárraga Madero" }, + "Q617112/patronymic-su/sorting": { + "patronymic": { + "expected": "Петрович", + "got": "" + }, + "text": "Кузнецов, Александр Петрович" + }, "Q61743963/surname2-es/givenFirst": { "family": { "expected": "Iglesias Pérez", @@ -3616,6 +5456,10 @@ "expected": "Arnbjörg Auður Örnólfsdóttir", "got": "Arnbjörg Auður" }, + "patronymic": { + "expected": "Örnólfsdóttir", + "got": "" + }, "text": "Arnbjörg Auður Örnólfsdóttir" }, "Q61972280/patronymic-is/givenFirst": { @@ -3627,6 +5471,10 @@ "expected": "Dóra Sigurðsson", "got": "Dóra" }, + "patronymic": { + "expected": "Sigurðsson", + "got": "" + }, "text": "Dóra Sigurðsson" }, "Q61972351/patronymic-is/givenFirst": { @@ -3638,6 +5486,10 @@ "expected": "Elísabet Erlingsdóttir", "got": "Elísabet" }, + "patronymic": { + "expected": "Erlingsdóttir", + "got": "" + }, "text": "Elísabet Erlingsdóttir" }, "Q61972491/patronymic-is/givenFirst": { @@ -3649,6 +5501,10 @@ "expected": "Guðmunda Elíasdóttir", "got": "Guðmunda" }, + "patronymic": { + "expected": "Elíasdóttir", + "got": "" + }, "text": "Guðmunda Elíasdóttir" }, "Q61973674/patronymic-is/givenFirst": { @@ -3660,6 +5516,10 @@ "expected": "Sigrún Jónsdóttir", "got": "Sigrún" }, + "patronymic": { + "expected": "Jónsdóttir", + "got": "" + }, "text": "Sigrún Jónsdóttir" }, "Q61974032/patronymic-is/givenFirst": { @@ -3671,6 +5531,10 @@ "expected": "Þuríður Pálsdóttir", "got": "Þuríður" }, + "patronymic": { + "expected": "Pálsdóttir", + "got": "" + }, "text": "Þuríður Pálsdóttir" }, "Q62837944/surname2-es/givenFirst": { @@ -3695,6 +5559,20 @@ }, "text": "Rosa Castillo Santiago" }, + "Q63196700/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Александр Александрович Кухаренко" + }, + "Q63196700/patronymic-ru/sorting": { + "patronymic": { + "expected": "Александрович", + "got": "" + }, + "text": "Кухаренко, Александр Александрович" + }, "Q63761164/surname2-es/givenFirst": { "family": { "expected": "Fernández Alonso", @@ -3728,6 +5606,34 @@ }, "text": "Miguel García Martínez" }, + "Q65201524/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Сергей Дмитриевич Остапов" + }, + "Q658050/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Дмитриевич", + "got": "" + }, + "text": "Игорь Дмитриевич Серебряков" + }, + "Q66124882/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Германович", + "got": "" + }, + "text": "Геннадий Германович Комиссаров" + }, + "Q66124882/patronymic-ru/surnameFirst": { + "patronymic": { + "expected": "Германович", + "got": "" + }, + "text": "Комиссаров Геннадий Германович" + }, "Q66464825/surname2-mx/givenFirst": { "family": { "expected": "Landín Martínez", @@ -3748,6 +5654,10 @@ "expected": "Azhar Ibrahim", "got": "Azhar" }, + "patronymic": { + "expected": "Ibrahim", + "got": "" + }, "text": "Azhar Ibrahim" }, "Q6737805/surname2-es/givenFirst": { @@ -3772,6 +5682,13 @@ }, "text": "Mariano Aguilar Barrera" }, + "Q6849481/patronymic-su/sorting": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Адамович, Михаил Михайлович" + }, "Q690876/patronymic-is/givenFirst": { "family": { "expected": "", @@ -3781,6 +5698,10 @@ "expected": "Arnar Gunnlaugsson", "got": "Arnar" }, + "patronymic": { + "expected": "Gunnlaugsson", + "got": "" + }, "text": "Arnar Gunnlaugsson" }, "Q6963798/patronymic-is/givenFirst": { @@ -3792,6 +5713,10 @@ "expected": "Nanna Bryndís Hilmarsdóttir", "got": "Nanna Bryndís" }, + "patronymic": { + "expected": "Hilmarsdóttir", + "got": "" + }, "text": "Nanna Bryndís Hilmarsdóttir" }, "Q701362/surname2-es/givenFirst": { @@ -3805,6 +5730,27 @@ }, "text": "Jaime Mayor Oreja" }, + "Q701635/patronymic-ri/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Иосиф Владимирович Гурко" + }, + "Q714964/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Валерьевич", + "got": "" + }, + "text": "Артур Валерьевич Дмитриев" + }, + "Q714964/patronymic-ru/sorting": { + "patronymic": { + "expected": "Валерьевич", + "got": "" + }, + "text": "Дмитриев, Артур Валерьевич" + }, "Q723705/patronymic-is/givenFirst": { "family": { "expected": "", @@ -3814,6 +5760,10 @@ "expected": "Bjarni Viðarsson", "got": "Bjarni" }, + "patronymic": { + "expected": "Viðarsson", + "got": "" + }, "text": "Bjarni Viðarsson" }, "Q728833/surname2-co/givenFirst": { @@ -3838,6 +5788,13 @@ }, "text": "Ricardo Legorreta Vilchis" }, + "Q7429/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Александровна", + "got": "" + }, + "text": "Нина Александровна Андреева" + }, "Q7429440/surname2-mx/givenFirst": { "family": { "expected": "González Herrera", @@ -3849,6 +5806,27 @@ }, "text": "Saúl González Herrera" }, + "Q76718786/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Георгиевич", + "got": "" + }, + "text": "Николай Георгиевич Зырин" + }, + "Q7747/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Владимирович", + "got": "" + }, + "text": "Владимир Владимирович Путин" + }, + "Q78286708/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Владислав Иванович Мамонтов" + }, "Q7882106/patronymic-is/givenFirst": { "family": { "expected": "", @@ -3858,8 +5836,19 @@ "expected": "Una Guðmundsdóttir", "got": "Una" }, + "patronymic": { + "expected": "Guðmundsdóttir", + "got": "" + }, "text": "Una Guðmundsdóttir" }, + "Q8003/patronymic-su/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Сергей Михайлович Эйзенштейн" + }, "Q81619240/surname2-es/givenFirst": { "family": { "expected": "Aguirre Miramón", @@ -3893,6 +5882,13 @@ }, "text": "Anna Maria Martínez Sagi" }, + "Q82381/patronymic-ru/sorting": { + "patronymic": { + "expected": "Иванович", + "got": "" + }, + "text": "Фоменков, Валерий Иванович" + }, "Q8338233/surname2-mx/givenFirst": { "family": { "expected": "Romero Deschamps", @@ -3948,6 +5944,13 @@ }, "text": "Vicente Gómez Mier" }, + "Q86094778/patronymic-ru/sorting": { + "patronymic": { + "expected": "Фёдорович", + "got": "" + }, + "text": "Антипов, Павел Фёдорович" + }, "Q87124000/surname2-es/givenFirst": { "family": { "expected": "Abad Miró", @@ -3959,6 +5962,13 @@ }, "text": "Gabriela Abad Miró" }, + "Q87463195/patronymic-ru/givenFirst": { + "patronymic": { + "expected": "Арсентьевна", + "got": "" + }, + "text": "Наталья Арсентьевна Якубович" + }, "Q879568/patronymic-is/givenFirst": { "family": { "expected": "", @@ -3968,6 +5978,10 @@ "expected": "Björn Bjarnason", "got": "Björn" }, + "patronymic": { + "expected": "Bjarnason", + "got": "" + }, "text": "Björn Bjarnason" }, "Q8846206/surname2-mx/givenFirst": { @@ -4111,6 +6125,10 @@ "expected": "Kristinn Hrafnsson", "got": "Kristinn" }, + "patronymic": { + "expected": "Hrafnsson", + "got": "" + }, "text": "Kristinn Hrafnsson" }, "Q959666/surname2-co/givenFirst": { @@ -4177,6 +6195,10 @@ "expected": "Jón Árnason", "got": "Jón" }, + "patronymic": { + "expected": "Árnason", + "got": "" + }, "text": "Jón Árnason" }, "Q98069704/surname2-es/givenFirst": { @@ -4199,6 +6221,10 @@ "expected": "Jón Helgason", "got": "Jón" }, + "patronymic": { + "expected": "Helgason", + "got": "" + }, "text": "Jón Helgason" }, "Q99318879/surname2-es/givenFirst": { diff --git a/tools/differential/wikidata_native_agreement.json b/tools/differential/wikidata_native_agreement.json index d06b09211..27006b0f0 100644 --- a/tools/differential/wikidata_native_agreement.json +++ b/tools/differential/wikidata_native_agreement.json @@ -76,6 +76,13 @@ }, "text": "上山輝一" }, + "Q105302343/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Сергійович", + "got": "" + }, + "text": "Федоров Іван Сергійович" + }, "Q108855843/ja-jp/surnameFirst": { "family": { "expected": "森", @@ -450,6 +457,13 @@ }, "text": "渋谷健一" }, + "Q118224301/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Іллівна", + "got": "" + }, + "text": "Степаненко Василиса Іллівна" + }, "Q121061159/ar-sy/givenFirst": { "family": { "expected": "ذو الغنى", @@ -461,6 +475,27 @@ }, "text": "عائشة ذو الغنى" }, + "Q12123956/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Олексійович", + "got": "" + }, + "text": "Мовчан Борис Олексійович" + }, + "Q12141961/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Степанович", + "got": "" + }, + "text": "Полунін Михайло Степанович" + }, + "Q12168221/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Васильович", + "got": "" + }, + "text": "Царук Олександр Васильович" + }, "Q12221498/ar-eg/givenFirst": { "family": { "expected": "عبد العزيز", @@ -615,6 +650,13 @@ }, "text": "伊豆田莉奈" }, + "Q16172176/uk-ua/givenFirst": { + "patronymic": { + "expected": "Олексійович", + "got": "" + }, + "text": "Олексій Олексійович Гончаренко" + }, "Q16230492/ar-eg/givenFirst": { "family": { "expected": "شكر الله", @@ -703,6 +745,13 @@ }, "text": "沈鵬" }, + "Q20068674/uk-ua/givenFirst": { + "patronymic": { + "expected": "Михайлович", + "got": "" + }, + "text": "Едуард Михайлович Москальов" + }, "Q2017050/ja-jp/surnameFirst": { "family": { "expected": "岡", @@ -769,6 +818,13 @@ }, "text": "罗隆基" }, + "Q232995/uk-ua/givenFirst": { + "patronymic": { + "expected": "Сергіївна", + "got": "" + }, + "text": "Оксана Сергіївна Баюл" + }, "Q233902/ja-jp/surnameFirst": { "family": { "expected": "朴", @@ -824,6 +880,13 @@ }, "text": "陈静" }, + "Q25490264/kk-kz/givenFirst": { + "patronymic": { + "expected": "Болатұлы", + "got": "" + }, + "text": "Қайрат Болатұлы Тұрлыханов" + }, "Q263322/zh-tw/surnameFirst": { "family": { "expected": "趙", @@ -901,6 +964,13 @@ }, "text": "陳明忠" }, + "Q28664784/kk-kz/givenFirst": { + "patronymic": { + "expected": "Васильевна", + "got": "" + }, + "text": "Татьяна Васильевна Костина" + }, "Q313598/zh-tw/surnameFirst": { "family": { "expected": "蔣", @@ -1000,6 +1070,34 @@ }, "text": "桃田賢斗" }, + "Q4234503/uk-ua/givenFirst": { + "patronymic": { + "expected": "Олександрович", + "got": "" + }, + "text": "Дмитро Олександрович Корчинський" + }, + "Q4269586/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Ігорович", + "got": "" + }, + "text": "Луньов Святослав Ігорович" + }, + "Q4373277/uk-ua/givenFirst": { + "patronymic": { + "expected": "Володимирович", + "got": "" + }, + "text": "Андрій Володимирович Портнов" + }, + "Q4417458/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Кузьмич", + "got": "" + }, + "text": "Середа Григорій Кузьмич" + }, "Q445244/zh-cn/surnameFirst": { "family": { "expected": "郭", @@ -1187,6 +1285,20 @@ }, "text": "東郷文彦" }, + "Q629702/uk-ua/surnameFirst": { + "patronymic": { + "expected": "Данилович", + "got": "" + }, + "text": "Кучма Леонід Данилович" + }, + "Q651264/uk-ua/givenFirst": { + "patronymic": { + "expected": "Володимирович", + "got": "" + }, + "text": "Андрій Володимирович Бондар" + }, "Q6538654/zh-cn/surnameFirst": { "family": { "expected": "李", @@ -1528,6 +1640,13 @@ }, "text": "李俊賢" }, + "Q981187/kk-kz/givenFirst": { + "patronymic": { + "expected": "Болатұлы", + "got": "" + }, + "text": "Дәулет Болатұлы Тұрлыханов" + }, "Q99630410/zh-cn/surnameFirst": { "family": { "expected": "黄", From 90c97eaf7d183aae683c82b224d1081029688c5e Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 20:25:38 -0700 Subject: [PATCH 2/6] Keep P6's patronymic under middle_as_family; review fixes (4c) P6's attachment and O3's fold both write the folded mark, so the attached particle is now told by its shape, not its field. A Turkic father is read only once its marker reads as a name word, and the arm reads the maiden name as the particle arm does. The user docs no longer say the ending rules fill the view under every order. Each clause the review found untested has a test, a benchmark row guards the opt-in arms, and R9 records the accepted shapes. Co-Authored-By: Claude Opus 5.5 --- docs/customize.rst | 5 +- docs/design/decisions/R9-patronymic-view.md | 3 +- docs/design/rules/R9-patronymic-view.md | 25 +++- docs/locales.rst | 8 +- docs/release_log.rst | 2 +- nameparser/_pipeline/_assemble.py | 123 +++++++++++++------- nameparser/_pipeline/_vocab.py | 7 +- tests/v2/rules_budget.json | 2 +- tests/v2/test_benchmark.py | 11 ++ tests/v2/test_cases.py | 5 +- tests/v2/test_types.py | 47 +++++++- tools/differential/corpus_rules.jsonl | 9 ++ tools/differential/snapshot.jsonl | 9 ++ 13 files changed, 195 insertions(+), 61 deletions(-) diff --git a/docs/customize.rst b/docs/customize.rst index 058bfe0c4..d82b30683 100644 --- a/docs/customize.rst +++ b/docs/customize.rst @@ -681,8 +681,9 @@ listed below. Slavic formal order (``EAST_SLAVIC``) or Turkic reversed order (``TURKIC``) — but stands down under a declared ``FAMILY_FIRST`` or ``FAMILY_FIRST_GIVEN_LAST`` ``name_order``. - Each rule also fills ``ParsedName.patronymic``, under every - order. Defaults to empty. + Each rule also fills ``ParsedName.patronymic``, reading a middle + name with the East Slavic ending, and a Turkic marker with the + word before it. Defaults to empty. * - ``middle_as_family`` - ``bool`` - Folds ``middle`` into ``family`` instead of splitting them — diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md index 65ec13b6c..08cd17ada 100644 --- a/docs/design/decisions/R9-patronymic-view.md +++ b/docs/design/decisions/R9-patronymic-view.md @@ -20,4 +20,5 @@ Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's wor 5. **The report's alternative moves with the reading.** A leading patronymic particle read as a given name names `patronymic as family` where it named `particle as family` (`bin Mohamad`), through `_vocab.particle_reading`, which both of assign's particle alternatives now ask, so `Token.reading` and `Ambiguity.alternative` agree, as R8's 4b made them for `Jr`. 6. **The ending rules read a word, not only a rotation.** O1 and O2 fire on exactly three and four name words; the patronymic is written in more shapes than that (`Ivanov, Ivan Ivanovich`, `Ivan Ivanovich Ivanov`, which the rotation leaves alone), so the East Slavic arm reads a middle word carrying O1's ending, and the Turkic arm a marker in a name field with the name word before it in its comma part, wherever the rule is on. The middle is the field a patronymic stands in after a given name, and a family word carrying the ending is a surname as often as not (`Roman Abramovich`). The patterns moved from `_post_rules` to `_vocab` as `has_patronymic_ending` and `is_patronymic_marker`, which the rotations and assemble both ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION), the packs' hand copies pinned against them there. 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. - - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 18 names, 6,588 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse): the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees; a patronymic particle adds one (`Mahathir bin Mohamad` 217 to 218), P6's attached shape nine (`Mohamad, Mahathir bin` 263 to 272, its family's tokens rebuilt), and the opt-in rules two (`Ivan Ivanovich Ivanov` under RU, 211 to 213) and eight (`Ali Ahmad Vali oglu` under TR_AZ, 243 to 251). + 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the attachment is now told by its shape, a folded family particle with nothing after it in its field behind a family word not folded, which O3's folded middles never are, since they stand before the family under the default order and have a word after them under a family-first one. (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, rewritten after the walk as P6's family is. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Aliyeva qizi` gives 'Aliyeva qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 18 names, 6,588 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the review fixes below: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 13 (`Mohamad, Mahathir bin` 263 to 276, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse and one per word in a name field or the maiden name, and three more rebuilding each father's token (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 259). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. diff --git a/docs/design/rules/R9-patronymic-view.md b/docs/design/rules/R9-patronymic-view.md index 53c26b3ed..5e400449a 100644 --- a/docs/design/rules/R9-patronymic-view.md +++ b/docs/design/rules/R9-patronymic-view.md @@ -6,7 +6,7 @@ - **A patronymic particle**: a particle the shipped vocabulary holds as a patronymic, read as a particle, with the words after it in its field, the parent's name. - **The East Slavic rule** (`PatronymicRule.EAST_SLAVIC`, which RU turns on): a middle word carrying O1's patronymic ending. -- **The Turkic rule** (`PatronymicRule.TURKIC`, which TR_AZ turns on): a standalone patronymic marker, O2's, with the name word before it in its comma part, the father's name. +- **The Turkic rule** (`PatronymicRule.TURKIC`, which TR_AZ turns on): a standalone patronymic marker, O2's, with the name word before it in its comma part, the father's name, a marker in the maiden name taking the maiden name's word before it. **Because:** a patronymic names the parent, which a caller addressing the bearer or matching records needs to tell from a family name, and the people filling in fielded records, like CLDR's samples, put it in the family or the given part. Naming it in a view of its own leaves every field where those sources put it. The view reads only a word or a rule that says patronymic, never a position, since nothing in a name's shape says which name word is a parent's. @@ -21,12 +21,14 @@ "محمد بن سلمان" → patronymic="بن سلمان" "Mahathir bin Mohamad Jr." → patronymic="bin Mohamad" "bin Mohamad, Mahathir" → patronymic="bin Mohamad" +"Siti Hasan née binti Ali" → patronymic="binti Ali" "Ivan Ivanovich Ivanov" [ru] → patronymic="Ivanovich" "Ivan Ivanovich Ivanov" [ru] → middle="Ivanovich" "Sidorov Ivan Petrovich" [ru] → patronymic="Petrovich" "Ivanov, Ivan Ivanovich" [ru] → patronymic="Ivanovich" "Aliyev, Heydar Alirza oglu" [tr_az] → patronymic="Alirza oglu" "Ali Ahmad Vali oglu" [tr_az] → patronymic="Vali oglu" +"Leyla née Aliyeva qizi" [tr_az] → patronymic="Aliyeva qizi" "Ivan Ivanovich Ivanov" → patronymic="" · boundary "Anwar Ibrahim" → patronymic="" ``` @@ -42,18 +44,20 @@ ``` "Fritz Freiherr und von Bar" → patronymic="" ``` -- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, the view writing them in the family's order. +- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, the view writing them in the family's order, under `middle_as_family` too. ``` "Mohamad, Mahathir bin" → patronymic="bin Mohamad" + "Levi, Omer Bar" → patronymic="Bar Levi" ``` - **A nickname inside the parent's name** is skipped, not a word of the view. ``` "Ahmad bin “Bob” Ali" → patronymic="bin Ali" ``` -- **A Turkic marker with no name word before it in its comma part** names no father and is no patronymic. +- **A Turkic marker with no name word before it in its comma part** names no father and is no patronymic, and neither does one in the maiden name with no maiden word before it. ``` "oglu Ali" [tr_az] → patronymic="" "Aliyev, oglu" [tr_az] → patronymic="" + "Ali née oglu" [tr_az] → patronymic="" ``` **Accepted:** @@ -65,10 +69,23 @@ ``` - The East Slavic arm reads the middle only, the field the parse puts a patronymic in after a given name: one the parse put in the family (a two-word `Ivan Petrovich`) or in the given name (FAMILY_FIRST_GIVEN_LAST's last word) is not read, and under a declared FAMILY_FIRST a surname the order puts in the middle is, `Иван Петрович Абрамович` reading patronymic `Абрамович`, the declaration O1 honors. - The East Slavic endings are O1's, so a Ukrainian `-ївна` (`Оксана Сергіївна Баюл`) is not read. -- The Turkic father's name may stand in another field than the marker, as O2 leaves a three-word name. +- The Turkic father's name may stand in another field than the marker, as O2 leaves a three-word name, and a two-word name with a marker is read whole, a family name spelled as a marker included. ``` "Ali Ahmad oglu" [tr_az] → patronymic="Ahmad oglu" + "Ali oglu" [tr_az] → patronymic="Ali oglu" + "Mehmet Oğlu" [tr_az] → patronymic="Mehmet Oğlu" ``` +- A word inside the particle's run is in it whatever it is read as, a title included. + ``` + "Ahmad bin Dr. Ali" → patronymic="bin Dr. Ali" + ``` +- A particle is read only as a word of its own, so one glued to the name by a hyphen is not, nor one written with its vowel marks, which the vocabulary does not list. + ``` + "Ahmad ibn-Ali" → patronymic="" + "مُحَمَّد بِن سَلمان" → patronymic="" + ``` +- Under `two_family_names` O6 takes the middle word into the family as a first family name, leaving the East Slavic arm no middle to read, so `Ivan Ivanovich Ivanov` under RU with it reads family `Ivanovich Ivanov` and no patronymic. +- A word with no reading, one that `replace()` or `Parser.revise()` wrote or in a name unpickled from 2.x, is not in the view, since nothing records what it was read as. - A patronymic particle is the shipped vocabulary's, read whatever the reading's default, as R8 reads a suffix word's kind, so a word only a caller's `Lexicon` lists as a particle (`ben`, which the vocabulary excludes as a patronymic) is a plain particle. - A word the particle's run or the Turkic marker takes reads `patronymic` by position, its own reading (a name word, a bound given name, a particle) not kept in `Token.reading`. diff --git a/docs/locales.rst b/docs/locales.rst index 8d0c86e60..5414d5689 100644 --- a/docs/locales.rst +++ b/docs/locales.rst @@ -329,9 +329,11 @@ order already reads ``"Сидоров Иван Петрович"`` as family `` so folding the pack onto a base built with it adds nothing. Under ``FAMILY_FIRST_GIVEN_LAST`` the declared order wins, and the last word — the patronymic, or a separate ``oglu``/``qizi`` marker — reads -as the given name. ``patronymic`` does not stand down: it reads a middle -word with the ending, or a marker and the word before it, under every -order, wherever the declared order put them. +as the given name. ``patronymic`` does not stand down, but it reads only +a middle name with the ending, so it follows the order: under +``FAMILY_FIRST`` ``Ivan Ivanovich Ivanov`` reads given ``Ivanovich`` +and no patronymic. A marker and the word before it are read under +every order. .. _segmenter-contract: diff --git a/docs/release_log.rst b/docs/release_log.rst index b5c3f326f..75877e435 100644 --- a/docs/release_log.rst +++ b/docs/release_log.rst @@ -8,7 +8,7 @@ Release Log - **Add Locale.languages, a choice of languages for the default vocabulary.** ``parser_for(Locale(code="pt", lexicon=Lexicon.empty(), languages=frozenset({"pt"}))).parse("ANA SILVA E SOUZA")`` gives family ``SILVA E SOUZA``, where the default reads family ``SOUZA``, the ``E`` an initial: a chosen language's reading of a word it has a vocabulary reading of wins, and the other languages' vocabulary readings of that word leave, while its readings as a name and the readings shared by every language stay, so under ``pt`` ``John Smith DO`` reads family ``DO``. A generation word another language collides with is read only under its own language: ``Jiří Dienstbier ml.`` gives suffix ``ml.`` under ``cs``, and the Czech, Slovak, Slovenian and Croatian ``ml.`` and ``st.`` and the French ``père`` read this way. A pack choosing no language parses as before. See :doc:`locales`. :issue:`753` - **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``, ``ibn``, ``bint``, ``bar``, ``bat`` and their Arabic and Hebrew forms) or a rule a locale turns on, so ``Anwar Ibrahim`` has none. ``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.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 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/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py index 0d11e30c4..8197f57ef 100644 --- a/nameparser/_pipeline/_assemble.py +++ b/nameparser/_pipeline/_assemble.py @@ -277,52 +277,79 @@ def _opens_its_segment(state: ParseState, i: int) -> bool: and state.tokens[j].role is not Role.TITLE), None) == i +#: The fields a Turkic marker and its father's name may stand in: the +#: name's own and the maiden name, a name of the same shape. +_PATRONYMIC_FIELDS = frozenset({*NAME_ROLES, Role.MAIDEN}) + + # rules/R9: "a standalone patronymic marker, O2's, with the name word # before it in its comma part" -- the Turkic arm (decisions/R9, #754) def _turkic_patronymics(state: ParseState, - dropped: set[int]) -> tuple[set[int], set[int]]: - """The markers rules/R9's Turkic arm reads as a patronymic, and the - father's name before each: a marker standing in a name field, with - the word before it, a nickname aside, standing in one too and in - the marker's own comma part. Asked only where the policy turns the - rule on.""" - markers: set[int] = set() - fathers: set[int] = set() + dropped: set[int]) -> dict[int, int]: + """The markers rules/R9's Turkic arm may read as a patronymic, each + with the father's name before it: a marker standing in a name field + or the maiden name, with the word before it, a nickname aside, + standing in one too, the maiden name's own where the marker stands + there, and in the marker's own comma part. The father is read once + its marker reads as a name word. Asked only where the policy turns + the rule on.""" + markers: dict[int, int] = {} tokens = state.tokens commas = state.comma_offsets for i, t in enumerate(tokens): - if (i in dropped or t.role not in NAME_ROLES + if (i in dropped or t.role not in _PATRONYMIC_FIELDS or not is_patronymic_marker(t.text)): continue j = i - 1 while j >= 0 and (j in dropped or tokens[j].role is Role.NICKNAME): j -= 1 - if (j >= 0 and tokens[j].role in NAME_ROLES + if (j >= 0 and tokens[j].role in _PATRONYMIC_FIELDS + and (tokens[j].role is Role.MAIDEN) is (t.role is Role.MAIDEN) and comma_bucket(tokens[j].span.start, commas) == comma_bucket(t.span.start, commas)): - markers.add(i) - fathers.add(j) - return markers, fathers + markers[i] = j + return markers -def _opens_a_patronymic(state: ParseState, dropped: set[int], i: int, - field: Role) -> bool: +def _patronymic_opening(state: ParseState, dropped: set[int], i: int, + field: Role, folds_middle: bool) -> tuple[bool, bool]: """Whether the patronymic particle at `i`, read as a particle, names - a parent (rules/R9): a word after it in its field, a nickname - aside, or the family written before it that P6 attached it to - ('Mohamad, Mahathir bin'). One ending its field names nobody, a - family word spelled as one ('Bar' in 'Fritz von Bar'). Asked only - of a patronymic particle, so other names pay nothing.""" + a parent (rules/R9), and whether it names it as P6's attachment: + a word after it in its field, a nickname aside, is the parent's + name; with none, a particle P6 attached to the family written + before it ('Mohamad, Mahathir bin') names that family. One ending + its field otherwise names nobody, a family word spelled as one + ('Bar' in 'Fritz von Bar'). Asked only of a patronymic particle, + so other names pay nothing.""" tokens = state.tokens - tags = tokens[i].tags - if field is Role.FAMILY and FOLDED_TAG in tags: - return True j = i + 1 while j < len(tokens) and (j in dropped or tokens[j].role is Role.NICKNAME): j += 1 - return (j < len(tokens) and tokens[j].role is tokens[i].role - and (FOLDED_TAG in tokens[j].tags) == (FOLDED_TAG in tags)) + if j < len(tokens) and _field_of(tokens[j], folds_middle) is field: + return True, False + # P6's attachment and O3's middle fold both write FOLDED_TAG, so the + # attachment is told by its shape, not its field: a folded family + # word with nothing after it in its field, behind a family word not + # folded. O3's folded middles stand before the family under the + # default order, so none has one behind, and under a family-first + # order the attachment takes a trailing particle first, a folded + # middle then having a word after it, which the test above read + tok = tokens[i] + if tok.role is not Role.FAMILY or FOLDED_TAG not in tok.tags: + return False, False + attached = any(tokens[k].role is Role.FAMILY + and FOLDED_TAG not in tokens[k].tags + for k in range(i) if k not in dropped) + return attached, attached + + +def _field_of(token: WorkToken, folds_middle: bool) -> Role: + """The field a token stands as: its role, save a middle word the + policy folded into the family, which stands as the middle.""" + role = token.role if token.role is not None else Role.GIVEN + return (Role.MIDDLE if role is Role.FAMILY and folds_middle + and FOLDED_TAG in token.tags else role) def _listed_suffix(text: str, lexicon: Lexicon) -> bool: @@ -360,8 +387,10 @@ def assemble(state: ParseState) -> ParsedName: attached = False rules = state.policy.patronymic_rules slavic = PatronymicRule.EAST_SLAVIC in rules - markers, fathers = (_turkic_patronymics(state, dropped) - if PatronymicRule.TURKIC in rules else ((), ())) + markers = (_turkic_patronymics(state, dropped) + if PatronymicRule.TURKIC in rules else {}) + # the father's names of the markers read, rewritten after the walk + fathers: list[int] = [] for i, t in enumerate(state.tokens): if i in dropped: continue @@ -388,6 +417,8 @@ def assemble(state: ParseState) -> ParsedName: # frame (decisions/vocabulary-model, 2026-10-29). reading: Reading | None = None if role is Role.SUFFIX: + # a suffix ends a particle's run: the family can resume + # behind it ('Hasan' in 'bin Ahmad Jr. Hasan, Ali') run_field = None kind, source = suffix_kind(t.text, tags) if (source is ReadingSource.VOCABULARY @@ -486,30 +517,36 @@ def assemble(state: ParseState) -> ParsedName: kind, source = ReadingKind.NAME, name_source(tags) # a name's files by its field, a middle word the policy # folded into the family by the middle it stands as + # (inline, not `_field_of`: a frame per token, measured + # 2026-10-29 at 7 on the reference row) field = (Role.MIDDLE if role is Role.FAMILY and folds_middle and FOLDED_TAG in tags else role) # rules/R9: "a particle the shipped vocabulary holds as a # patronymic", read as a particle, opens a patronymic that # takes the words after it in its field; an ending rule a # policy turns on reads its own words (decisions/R9, #754) - if (kind is ReadingKind.PARTICLE and PATRONYMIC_TAG in tags - and _opens_a_patronymic(state, dropped, i, field)): + opens = attaches = False + if kind is ReadingKind.PARTICLE and PATRONYMIC_TAG in tags: + opens, attaches = _patronymic_opening(state, dropped, i, + field, folds_middle) + if opens: kind = ReadingKind.PATRONYMIC run_field = field - attached = attached or (field is Role.FAMILY - and FOLDED_TAG in tags) + attached = attached or attaches elif field is run_field: kind, source = ReadingKind.PATRONYMIC, ReadingSource.POSITION else: if role is not Role.NICKNAME: run_field = None - if i in fathers: - kind, source = (ReadingKind.PATRONYMIC, - ReadingSource.POSITION) - elif kind is ReadingKind.NAME and ( + # a marker or an ending read as a name word: one a + # caller's Lexicon lists as a connective keeps that + # reading, and its father with it + if kind is ReadingKind.NAME and ( i in markers or slavic and field is Role.MIDDLE and has_patronymic_ending(t.text)): kind, source = ReadingKind.PATRONYMIC, ReadingSource.SHAPE + if i in markers: + fathers.append(markers[i]) langs = _languages(t.text, kind, field) if source in _LISTED else () if (not langs and source in _LISTED and (langs := _part_languages(t.text, kind, field)) @@ -528,13 +565,15 @@ def assemble(state: ParseState) -> ParsedName: # rules/R9: "A trailing patronymic particle P6 attaches to the # family written before it takes that family's words as the # parent's name" (decisions/R9, #754) - for j, tok in final.items(): - if (tok.role is Role.FAMILY and FOLDED_TAG not in tok.tags - and tok.reading is not None - and tok.reading.kind is not ReadingKind.PATRONYMIC): - final[j] = Token(tok.text, tok.span, tok.role, tok.tags, - _READINGS[ReadingKind.PATRONYMIC, Role.FAMILY, - ReadingSource.POSITION]) + fathers.extend(j for j, tok in final.items() + if tok.role is Role.FAMILY + and FOLDED_TAG not in tok.tags) + for j in fathers: + tok = final[j] + if tok.reading is not None and tok.reading.kind is not ReadingKind.PATRONYMIC: + final[j] = Token(tok.text, tok.span, tok.role, tok.tags, + _READINGS[ReadingKind.PATRONYMIC, tok.role, + ReadingSource.POSITION]) # No alphanumeric character among the SURVIVING tokens means no # name: a bare '.' or '- -' is not a person. v1 kept such input # (parse('.') -> first '.'); 2.0 empties it so bool() stays an diff --git a/nameparser/_pipeline/_vocab.py b/nameparser/_pipeline/_vocab.py index 42c298119..493672eab 100644 --- a/nameparser/_pipeline/_vocab.py +++ b/nameparser/_pipeline/_vocab.py @@ -275,6 +275,10 @@ def is_initial_shaped(text: str) -> bool: # v1 regexes.py "roman_numeral", pinned by tests/v2/test_regex_sync.py. _ROMAN = re.compile(r'^(X|IX|IV|V?I{0,3})$', re.I) +#: The same pattern, shared rather than copied: `_comma` filters on +#: it before asking `reads_as_generation` (rules/C12). +ROMAN = _ROMAN + # Ported verbatim from v1 (nameparser/config/regexes.py), pinned by # tests/v2/test_regex_sync.py: the East Slavic patronymic endings and @@ -305,9 +309,6 @@ def is_patronymic_marker(text: str) -> bool: "кызы"): rules/O2's test and, under `PatronymicRule.TURKIC`, rules/R9's.""" return bool(_TURKIC.match(text) or _TURKIC_CYR.match(text)) -#: The same pattern, shared rather than copied: `_comma` filters on -#: it before asking `reads_as_generation` (rules/C12). -ROMAN = _ROMAN # rules/S10: "the numeral reads as the generation: the numerals the diff --git a/tests/v2/rules_budget.json b/tests/v2/rules_budget.json index 16356a04f..948bf8a8e 100644 --- a/tests/v2/rules_budget.json +++ b/tests/v2/rules_budget.json @@ -57,7 +57,7 @@ "R6": {"words": 400, "then_words": 265, "because_sentences": 2, "except": 1, "precedence": 0, "accepted": 5, "accepted_words": 289}, "R7": {"words": 142, "then_words": 30, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 2, "accepted_words": 74}, "R8": {"words": 163, "then_words": 61, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 7, "accepted_words": 291}, - "R9": {"words": 347, "then_words": 110, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 7, "accepted_words": 262}, + "R9": {"words": 377, "then_words": 123, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 11, "accepted_words": 402}, "S1": {"words": 306, "then_words": 26, "because_sentences": 1, "except": 2, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S2": {"words": 657, "then_words": 119, "because_sentences": 1, "except": 0, "precedence": 7, "accepted": 3, "accepted_words": 222}, "S3": {"words": 960, "then_words": 398, "because_sentences": 3, "except": 4, "precedence": 5, "accepted": 6, "accepted_words": 343}, diff --git a/tests/v2/test_benchmark.py b/tests/v2/test_benchmark.py index ecc28e301..a905c460d 100644 --- a/tests/v2/test_benchmark.py +++ b/tests/v2/test_benchmark.py @@ -615,6 +615,17 @@ def test_prefixed_cost_grows_no_worse_than_linearly( # must run for this to read "b" rather than "" or "née b" lambda p: p.parse("a (née b)").maiden == "b", ), + # rules/R9's ending-rule arms, which walk every name word once the + # policy turns a rule on (a marker and its father, a middle word's + # ending), and only then: the probe reads both + "patronymic_rules": ( + "Ali oglu Petrovich ", + Parser(policy=Policy(patronymic_rules=frozenset( + {"east-slavic", "turkic"}))), # type: ignore[arg-type] + lambda p: (p.parse("Ali Ahmad oglu").patronymic == "Ahmad oglu" + and p.parse("Ivan Ivanovich Ivanov").patronymic + == "Ivanovich"), + ), } diff --git a/tests/v2/test_cases.py b/tests/v2/test_cases.py index 0c5b60fc5..fa23311c9 100644 --- a/tests/v2/test_cases.py +++ b/tests/v2/test_cases.py @@ -89,10 +89,11 @@ def test_the_family_and_the_suffix_partition_into_their_views( rules/R8's views partition the suffix the same way, its words exactly the generation's plus the credentials', and join this walk rather than parsing the grid again; so does rules/R9's - patronymic, which names words the name fields keep. + patronymic, which names words the name fields and the maiden name + keep. """ pn = _swept(case.id, order) - named = Counter(f"{pn.given} {pn.middle} {pn.family}".split()) + named = Counter(f"{pn.given} {pn.middle} {pn.family} {pn.maiden}".split()) assert not Counter(pn.patronymic.split()) - named, ( f"{case.text!r}: patronymic={pn.patronymic!r} names a word no " f"name field holds") diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index 62df29fb7..69898d622 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -1089,11 +1089,54 @@ def test_the_patronymic_view_reads_a_particle_or_a_rule( assert pn.family == field +_MAF = Policy(middle_as_family=True) + + +@pytest.mark.parametrize("policy,text,patronymic", [ + # P6's attachment is told by its shape, not by the field the + # middle fold reads its folded mark as, so middle_as_family keeps it + (Policy(), "Mohamad, Mahathir bin", "bin Mohamad"), + (_MAF, "Mohamad, Mahathir bin", "bin Mohamad"), + (Policy(), "Levi, Omer Bar", "Bar Levi"), + (_MAF, "Levi, Omer Bar", "Bar Levi"), + (Policy(name_order=FAMILY_FIRST, middle_as_family=True), + "Mohamad Mahathir bin", "bin Mohamad"), + # a folded middle with a word behind it is a run, not an attachment + (Policy(name_order=FAMILY_FIRST, middle_as_family=True), + "Mohamad Mahathir bin Ali", "bin Ali"), + # a particle ending the family before a comma has no word after it + # in its field: the given name behind the comma is not its parent's + (Policy(), "Ali bin, Ali", ""), + (Policy(), "Ahmad bin, Ali", ""), + # P6 attaching two particles: the family's own words, not the + # other folded particle, are the parent's name + (Policy(name_order=FAMILY_FIRST), "Ahmad Ahmad de bin", "bin Ahmad"), + # a suffix ends the run, so a family word behind it is not in it + (Policy(), "bin Ahmad Jr. Hasan, Ali", "bin Ahmad"), +]) +def test_the_patronymic_run_and_the_attachment_under_each_policy( + policy: Policy, text: str, patronymic: str) -> None: + assert Parser(policy=policy).parse(text).patronymic == patronymic + + +def test_a_turkic_marker_a_caller_lists_as_a_connective_names_no_father( + ) -> None: + # the marker keeps the connective reading the caller's Lexicon gives + # it, so neither it nor the word before it is a patronymic + parser = Parser(lexicon=Lexicon.default().add(conjunctions={"oglu"}), + policy=Policy(patronymic_rules={"turkic"})) # type: ignore[arg-type] + pn = parser.parse("Ali Ahmad Vali oglu Hasan") + assert pn.patronymic == "" + assert [t.reading.kind.value for t in pn.tokens if t.reading] == [ + "name", "name", "name", "conjunction", "name"] + + def test_a_patronymic_word_with_no_reading_is_not_in_the_view() -> None: - # replace() records no reading (4a), so nothing says what the word - # was; a field it did not touch keeps its readings + # replace() and revise() record no reading (4a), so nothing says + # what the word was; a field they did not touch keeps its readings pn = parse("Mahathir bin Mohamad") assert pn.replace(family="bin Mohamad").patronymic == "" + assert Parser().revise(pn, family="bin Ali").patronymic == "" assert pn.replace(given="Anwar").patronymic == "bin Mohamad" diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 9b5b34b17..78cf07151 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -36,12 +36,16 @@ "Abu Bakar Salim" "Ahmad al-ʿAbbas" "Ahmad bin Abdul Rahman" +"Ahmad bin Dr. Ali" "Ahmad bin “Bob” Ali" +"Ahmad ibn-Ali" "Alexandre Dumas fils" "Ali Ahmad Vali oglu" "Ali Ahmad Vali oglu Jr." "Ali Ahmad oglu" "Ali bin Ahmad bin Hasan" +"Ali née oglu" +"Ali oglu" "Aliyev, Heydar Alirza oglu" "Aliyev, oglu" "Alves, Pedro Fils" @@ -664,6 +668,8 @@ "LT.GOV. JOHN DOE" "Lau, Wing" "Lau, Wing Commander" +"Levi, Omer Bar" +"Leyla née Aliyeva qizi" "Lloret Palol II, JA" "Lloret Palol II, Josep Antoni" "Lord Buddha Tamang" @@ -705,6 +711,7 @@ "Mary-​Jane Smith" "Mc Donald" "Md Abdul Karim" +"Mehmet Oğlu" "Mesnil de" "Mohamad, Mahathir bin" "Mohamed Ali Abd Allah" @@ -836,6 +843,7 @@ "Sir de" "Sir de Mesnil" "Sita Kumari K.C" +"Siti Hasan née binti Ali" "Siti binti Abu Bakar" "Smith" "Smith (Jones)" @@ -1129,6 +1137,7 @@ "جورج بوش الابن" "سلمان، محمد" "محمد بن سلمان" +"مُحَمَّد بِن سَلمان" "राम शर्मा साहब मोहन" "शर्मा, राम बाबू" "প্রকৌশলী. Sen" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 8c2fff8f4..4ebfa2267 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -113,7 +113,9 @@ {"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 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": "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."} @@ -154,6 +156,8 @@ {"name": "Ali Juma Hamisi", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} {"name": "Ali Kara Oğlu", "given": "Ali", "middle": "Kara", "family": "Oğlu", "initials": "A. K. O."} {"name": "Ali bin Ahmad bin Hasan", "given": "Ali", "middle": "bin Ahmad", "family": "bin Hasan", "initials": "A. A. H.", "patronymic": "bin Ahmad bin Hasan"} +{"name": "Ali née oglu", "family": "Ali", "maiden": "oglu", "initials": "A.", "capitalized_force": "Ali (Oglu)"} +{"name": "Ali oglu", "given": "Ali", "family": "oglu", "initials": "A. o.", "capitalized_force": "Ali Oglu"} {"name": "Ali, Amina", "given": "Amina", "family": "Ali", "initials": "A. A."} {"name": "Alice Rühle-Gerstel", "given": "Alice", "family": "Rühle-Gerstel", "initials": "A. R."} {"name": "Alina", "given": "Alina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} @@ -2295,6 +2299,8 @@ {"name": "Leopoldo de'Medici", "given": "Leopoldo", "family": "de'Medici", "initials": "L. d."} {"name": "Lerch, Magda von", "given": "Magda", "family": "von Lerch", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. L."} {"name": "Leschetizky, Walter", "given": "Walter", "family": "Leschetizky", "initials": "W. L."} +{"name": "Levi, Omer Bar", "given": "Omer", "family": "Bar Levi", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "O. L.", "capitalized_force": "Omer bar Levi", "patronymic": "Bar Levi"} +{"name": "Leyla née Aliyeva qizi", "family": "Leyla", "maiden": "Aliyeva qizi", "initials": "L.", "capitalized_force": "Leyla (Aliyeva Qizi)"} {"name": "Lezo, Blas de", "given": "Blas", "family": "de Lezo", "initials": "B. L."} {"name": "Liang, Shuming", "given": "Shuming", "family": "Liang", "initials": "S. L."} {"name": "Lianora Sanna", "given": "Lianora", "family": "Sanna", "initials": "L. S."} @@ -2564,6 +2570,7 @@ {"name": "Medici, Leopoldo de'", "given": "Leopoldo", "family": "de' Medici", "initials": "L. M."} {"name": "Medici, Lorenzo di Pierfranceso de'", "given": "Lorenzo", "middle": "di Pierfranceso", "family": "de' Medici", "initials": "L. P. M."} {"name": "Medici, Maddalena de'", "given": "Maddalena", "family": "de' Medici", "initials": "M. M."} +{"name": "Mehmet Oğlu", "given": "Mehmet", "family": "Oğlu", "initials": "M. O."} {"name": "Meister E. S.", "given": "Meister", "middle": "E.", "family": "S.", "initials": "M. E. S."} {"name": "Menada Fonda", "given": "Menada", "family": "Fonda", "initials": "M. F."} {"name": "Mendes de Almeida, Luciano", "given": "Luciano", "family": "Mendes de Almeida", "initials": "L. M. A."} @@ -3222,6 +3229,7 @@ {"name": "Sister Mary", "title": "Sister", "given": "Mary", "initials": "M."} {"name": "Sister Souljah", "title": "Sister", "given": "Souljah", "initials": "S."} {"name": "Sita Kumari K.C", "given": "Sita", "middle": "Kumari", "family": "K.C", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "S. K. K."} +{"name": "Siti Hasan née binti Ali", "given": "Siti", "family": "Hasan", "maiden": "binti Ali", "initials": "S. H.", "capitalized_force": "Siti Hasan (Binti Ali)", "patronymic": "binti Ali"} {"name": "Siti binti Abu Bakar", "given": "Siti", "family": "binti Abu Bakar", "initials": "S. B.", "capitalized_force": "Siti binti abu Bakar", "patronymic": "binti Abu Bakar"} {"name": "Siv Friðleifsdóttir", "given": "Siv", "family": "Friðleifsdóttir", "initials": "S. F."} {"name": "Skapti Ólafsson", "given": "Skapti", "family": "Ólafsson", "initials": "S. Ó."} @@ -5294,6 +5302,7 @@ {"name": "مولر، کاتیا", "order": "FAMILY_FIRST", "given": "کاتیا", "family": "مولر", "initials": "ک. م."} {"name": "ميري سيو ھامش واٽسن", "given": "ميري", "middle": "سيو ھامش", "family": "واٽسن", "initials": "م. س. ھ. و."} {"name": "مُحمَّد علي محجوب", "given": "مُحمَّد", "middle": "علي", "family": "محجوب", "initials": "م. ع. م."} +{"name": "مُحَمَّد بِن سَلمان", "given": "مُحَمَّد", "middle": "بِن", "family": "سَلمان", "initials": "م. ب. س."} {"name": "مُوفَّق دعبول", "given": "مُوفَّق", "family": "دعبول", "initials": "م. د."} {"name": "مۇرات كارىباي", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} {"name": "مۇللەر كەيت", "order": "FAMILY_FIRST", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} From 021268ed3d87fd3c36304e62584829d79951e909 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 20:26:08 -0700 Subject: [PATCH 3/6] Count R9's example names after the review round (4c) Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/R9-patronymic-view.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md index 08cd17ada..78612c9b1 100644 --- a/docs/design/decisions/R9-patronymic-view.md +++ b/docs/design/decisions/R9-patronymic-view.md @@ -21,4 +21,4 @@ Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's wor 6. **The ending rules read a word, not only a rotation.** O1 and O2 fire on exactly three and four name words; the patronymic is written in more shapes than that (`Ivanov, Ivan Ivanovich`, `Ivan Ivanovich Ivanov`, which the rotation leaves alone), so the East Slavic arm reads a middle word carrying O1's ending, and the Turkic arm a marker in a name field with the name word before it in its comma part, wherever the rule is on. The middle is the field a patronymic stands in after a given name, and a family word carrying the ending is a surname as often as not (`Roman Abramovich`). The patterns moved from `_post_rules` to `_vocab` as `has_patronymic_ending` and `is_patronymic_marker`, which the rotations and assemble both ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION), the packs' hand copies pinned against them there. 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the attachment is now told by its shape, a folded family particle with nothing after it in its field behind a family word not folded, which O3's folded middles never are, since they stand before the family under the default order and have a word after them under a family-first one. (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, rewritten after the walk as P6's family is. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Aliyeva qizi` gives 'Aliyeva qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. - - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 18 names, 6,588 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the review fixes below: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 13 (`Mohamad, Mahathir bin` 263 to 276, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse and one per word in a name field or the maiden name, and three more rebuilding each father's token (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 259). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 27 names, 6,597 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the review fixes below: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 13 (`Mohamad, Mahathir bin` 263 to 276, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse and one per word in a name field or the maiden name, and three more rebuilding each father's token (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 259). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. From d610fd6c534573baaa2b925e71425b9f132709b4 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 20:46:54 -0700 Subject: [PATCH 4/6] Record P6's attachment on the words it attaches; second review fixes (4c) The attachment was inferred from the folded mark's shape, which lost the family behind a second attached particle (Bakar, Ali bin Abu) and read a particle P6 declined and O3 folded as attached (Rahman, Al bin). P6 now marks what it attaches, assemble drops the mark, and a word P6 attached stands as the family under the fold. A Turkic father is built once, at its marker. An invariant grid holds middle_as_family to the default's patronymic wherever R2 reads alike; R9 records the cases it does not. Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/R9-patronymic-view.md | 7 +- docs/design/rules/R9-patronymic-view.md | 11 ++- nameparser/_pipeline/_assemble.py | 94 +++++++++++---------- nameparser/_pipeline/_assign.py | 4 +- nameparser/_pipeline/_post_rules.py | 4 +- nameparser/_pipeline/_state.py | 6 ++ tests/v2/rules_budget.json | 2 +- tests/v2/test_types.py | 66 ++++++++++++++- tools/differential/corpus_rules.jsonl | 4 +- tools/differential/snapshot.jsonl | 4 +- 10 files changed, 145 insertions(+), 57 deletions(-) diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md index 78612c9b1..43540e1b2 100644 --- a/docs/design/decisions/R9-patronymic-view.md +++ b/docs/design/decisions/R9-patronymic-view.md @@ -15,10 +15,11 @@ Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's wor - 2026-10-29 #754, sub-phase 4c — IMPLEMENTED item 3 of decisions/vocabulary-model's 2026-10-28 #754 entry. What the implementation decided, beyond that entry: 1. **Every word of a particle's patronymic reads `patronymic`.** The entry left open whether the particle reads `patronymic`, or only the name after it; the view's text is `bin Mohamad`, so a view over one reading kind needs both, and `ReadingKind.PATRONYMIC`'s docstring already gave "bin Mohamad" as the words it names. The particle's reading is the vocabulary's (`vocabulary`, its files `ar ms` for `bin`), each word after it `position`: what makes it a patronymic is where it stands, after the particle, so it names no file, and its own reading (a name word, a bound given name, a chained particle) is not kept. Weighed against: the particle alone reading `patronymic` and the view adding the words after it, which would put a run-walk in the view and make `Token.reading` say `name` for a word the view counts as the parent's name. 2. **The run is the field, read at assemble.** The words after the particle in its field, a nickname skipped and a suffix ending it, measured over the final roles rather than group's pieces, since P1's fold and P6's attachment move words after group and a leading never-given particle stays a lone piece for the fold to find. So the run is what the family or middle holds after the particle: `ibn Ali Hasan` gives `ibn Ali Hasan` by default and `ibn Ali` under a family-first order, as the family does. Under `middle_as_family` the run follows the field the word stands as, so a folded middle's run stops at the family's own words. - 3. **A particle names a parent only with a word after it**, or where P6 attached it after a family comma or a family-first order to the family written before it, whose words are then the run (`Mohamad, Mahathir bin` gives `bin Mohamad`, written in the family's order since P6's particle carries the folded mark the family writes first). The first draft read every patronymic particle read as a particle, and the snapshot showed `Fritz Freiherr und von Bar` reading patronymic `Bar`: the German family name spelled as Hebrew `bar`, ending its field, which group's chain had left a particle. + 3. **A particle names a parent only with a word after it**, or where P6 attached it after a family comma or a family-first order to the family written before it, whose words then join the run (`Mohamad, Mahathir bin` gives `bin Mohamad` and `Bakar, Ali bin Abu` `bin Abu Bakar`, written in the family's order since P6's words carry the folded mark the family writes first). P6 records the attachment on the words it attaches (`vocab:attached`, beside the folded mark, at both of its sites), which assemble reads and drops as it drops the kind tag, so it is read, not inferred: O3's middle fold writes the same folded mark, and two inferences from shape each lost a case (item 8). The first draft read every patronymic particle read as a particle, and the snapshot showed `Fritz Freiherr und von Bar` reading patronymic `Bar`: the German family name spelled as Hebrew `bar`, ending its field, which group's chain had left a particle. 4. **The kind is read at classify from the reading index**, as R8's suffix kind is: `particles` compiles every particle kind alike, so classify writes `vocab:patronymic` beside `particle` for a word the shipped files hold as a `patronymic` particle (`_vocab.is_patronymic_particle`, a memo over `_reading_index.holds_kind`), whatever the reading's default, and assemble drops the tag from the published token, a published tag making a name unequal to its 2.x pickle. Every patronymic particle has that kind only (`bar`, `bat`, `bin`, `bint`, `binte`, `binti`, `ibn` and the five native-script forms, 2026-10-29), so the lookup asks for the kind, not the first particle row. A word only a caller's `Lexicon` lists is a plain particle, `ben` included, which `ar.toml` excludes as a patronymic. 5. **The report's alternative moves with the reading.** A leading patronymic particle read as a given name names `patronymic as family` where it named `particle as family` (`bin Mohamad`), through `_vocab.particle_reading`, which both of assign's particle alternatives now ask, so `Token.reading` and `Ambiguity.alternative` agree, as R8's 4b made them for `Jr`. 6. **The ending rules read a word, not only a rotation.** O1 and O2 fire on exactly three and four name words; the patronymic is written in more shapes than that (`Ivanov, Ivan Ivanovich`, `Ivan Ivanovich Ivanov`, which the rotation leaves alone), so the East Slavic arm reads a middle word carrying O1's ending, and the Turkic arm a marker in a name field with the name word before it in its comma part, wherever the rule is on. The middle is the field a patronymic stands in after a given name, and a family word carrying the ending is a surname as often as not (`Roman Abramovich`). The patterns moved from `_post_rules` to `_vocab` as `has_patronymic_ending` and `is_patronymic_marker`, which the rotations and assemble both ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION), the packs' hand copies pinned against them there. 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. - 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the attachment is now told by its shape, a folded family particle with nothing after it in its field behind a family word not folded, which O3's folded middles never are, since they stand before the family under the default order and have a word after them under a family-first one. (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, rewritten after the walk as P6's family is. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Aliyeva qizi` gives 'Aliyeva qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. - - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 27 names, 6,597 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the review fixes below: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 13 (`Mohamad, Mahathir bin` 263 to 276, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse and one per word in a name field or the maiden name, and three more rebuilding each father's token (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 259). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. + 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the fix told the attachment by its shape, a folded family particle with nothing after it in its field behind a family word not folded. The second review found that shape wrong both ways: a particle P6 attached with another after it (`Bakar, Ali bin Abu`, a real naming pattern) opened a plain run and lost the family, reading 'bin Abu', under the default policy; and after a family comma, where the family is written first, a trailing particle P6 declines (no given word ahead of it) and O3 folds read as attached (`Rahman, Al bin` under `middle_as_family` gave 'bin Rahman'). P6 now marks what it attaches (item 3), and the field of a word P6 attached is the family under the fold, so a folded middle's run stops short of it (`Ali, Ahmad bin Ahmad Abu`). (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, built once its marker is read, the token held until then. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Rasim qizi` gives 'Rasim qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. + 9. **Accepted after the second review.** R2 reads a name part made only of particles as name words, and O3's fold can give such a part a base, so its particle reads as one under the fold alone (`Ali, bin bin Abu` gives 'bin Abu' under `middle_as_family`, none without); `test_middle_as_family_moves_no_patronymic_r2_does_not_move` holds the two policies to the same patronymic words wherever R2's marks agree, 4,494 of 5,080 parses of its grid. Two Turkic patronymics, one in the name and one in the maiden name, are one view (`Leyla Vali qizi née Rasim qizi`), as two nasab particles are. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 29 names, 6,599 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the second review's fixes: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape nine (`Mohamad, Mahathir bin` 263 to 272, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse (`_turkic_patronymics`), one per word in a name field or the maiden name (`is_patronymic_marker`, which O2's rotation also asks once of the last word), and two per marker found (`comma_bucket`), the father's token built once (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 251: one, four in the arm and one in O2, two). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. diff --git a/docs/design/rules/R9-patronymic-view.md b/docs/design/rules/R9-patronymic-view.md index 5e400449a..31a645342 100644 --- a/docs/design/rules/R9-patronymic-view.md +++ b/docs/design/rules/R9-patronymic-view.md @@ -28,7 +28,7 @@ "Ivanov, Ivan Ivanovich" [ru] → patronymic="Ivanovich" "Aliyev, Heydar Alirza oglu" [tr_az] → patronymic="Alirza oglu" "Ali Ahmad Vali oglu" [tr_az] → patronymic="Vali oglu" -"Leyla née Aliyeva qizi" [tr_az] → patronymic="Aliyeva qizi" +"Leyla née Rasim qizi" [tr_az] → patronymic="Rasim qizi" "Ivan Ivanovich Ivanov" → patronymic="" · boundary "Anwar Ibrahim" → patronymic="" ``` @@ -44,10 +44,11 @@ ``` "Fritz Freiherr und von Bar" → patronymic="" ``` -- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, the view writing them in the family's order, under `middle_as_family` too. +- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, with any word P6 attached after it, the view writing them in the family's order, under `middle_as_family` too. ``` "Mohamad, Mahathir bin" → patronymic="bin Mohamad" "Levi, Omer Bar" → patronymic="Bar Levi" + "Bakar, Ali bin Abu" → patronymic="bin Abu Bakar" ``` - **A nickname inside the parent's name** is skipped, not a word of the view. ``` @@ -86,6 +87,12 @@ ``` - Under `two_family_names` O6 takes the middle word into the family as a first family name, leaving the East Slavic arm no middle to read, so `Ivan Ivanovich Ivanov` under RU with it reads family `Ivanovich Ivanov` and no patronymic. - A word with no reading, one that `replace()` or `Parser.revise()` wrote or in a name unpickled from 2.x, is not in the view, since nothing records what it was read as. +- A name part made only of particles is name words (R2), and `middle_as_family`'s fold can give such a part a base, so it reads a patronymic under the fold alone: `Ali, bin bin Abu` reads patronymic `bin Abu` with the fold and none without. +- Two patronymics are one view, in the name and the maiden name alike. + ``` + "Ali bin Ahmad bin Hasan" → patronymic="bin Ahmad bin Hasan" + "Leyla Vali qizi née Rasim qizi" [tr_az] → patronymic="Vali qizi Rasim qizi" + ``` - A patronymic particle is the shipped vocabulary's, read whatever the reading's default, as R8 reads a suffix word's kind, so a word only a caller's `Lexicon` lists as a particle (`ben`, which the vocabulary excludes as a patronymic) is a plain particle. - A word the particle's run or the Turkic marker takes reads `patronymic` by position, its own reading (a name word, a bound given name, a particle) not kept in `Token.reading`. diff --git a/nameparser/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py index 8197f57ef..225244aca 100644 --- a/nameparser/_pipeline/_assemble.py +++ b/nameparser/_pipeline/_assemble.py @@ -38,7 +38,7 @@ from nameparser._lexicon import _VOCAB_FIELDS, Lexicon, _normalize from nameparser._pipeline._state import ( - AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, + AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, ATTACHED_TAG, CREDENTIAL_WORD_TAG, FIELD_READING, GENERATION_WORD_TAG, NAME_ROLES, PATRONYMIC_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, @@ -54,7 +54,7 @@ from nameparser._types import ( FOLDED_TAG, GIVEN_FIRST, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, Ambiguity, AmbiguityKind, ParsedName, Reading, ReadingKind, - ReadingSource, Role, Token, + ReadingSource, Role, Span, Token, ) #: Every Reading a token can take, built once: a `Reading` is three @@ -203,10 +203,11 @@ def _split_partner(tokens: tuple[WorkToken, ...], dropped: set[int], _reading = functools.lru_cache(maxsize=1024)(Reading) -#: classify's kind tags for a listed suffix word (rules/R8), which no +#: classify's kind tags for a listed suffix word (rules/R8) and a +#: patronymic particle, and P6's attachment mark (rules/R9), which no #: published token carries: `Token.reading` names the kind. _KIND_TAGS = frozenset({GENERATION_WORD_TAG, CREDENTIAL_WORD_TAG, - PATRONYMIC_TAG}) + PATRONYMIC_TAG, ATTACHED_TAG}) #: The tags that say a list or a shape made a word a suffix: without #: one, a suffix word stands there by position, unless the vocabulary @@ -314,42 +315,32 @@ def _turkic_patronymics(state: ParseState, def _patronymic_opening(state: ParseState, dropped: set[int], i: int, field: Role, folds_middle: bool) -> tuple[bool, bool]: """Whether the patronymic particle at `i`, read as a particle, names - a parent (rules/R9), and whether it names it as P6's attachment: - a word after it in its field, a nickname aside, is the parent's - name; with none, a particle P6 attached to the family written - before it ('Mohamad, Mahathir bin') names that family. One ending - its field otherwise names nobody, a family word spelled as one - ('Bar' in 'Fritz von Bar'). Asked only of a patronymic particle, - so other names pay nothing.""" + a parent (rules/R9), and whether it names it as P6's attachment: a + particle P6 attached to the family written before it ('Mohamad, + Mahathir bin', 'Bakar, Ali bin Abu') names that family, the words + attached after it with it; any other names the words after it in + its field, a nickname aside, and one ending its field names nobody, + a family word spelled as one ('Bar' in 'Fritz von Bar'). Asked only + of a patronymic particle, so other names pay nothing.""" tokens = state.tokens + if ATTACHED_TAG in tokens[i].tags: + return True, True j = i + 1 while j < len(tokens) and (j in dropped or tokens[j].role is Role.NICKNAME): j += 1 - if j < len(tokens) and _field_of(tokens[j], folds_middle) is field: - return True, False - # P6's attachment and O3's middle fold both write FOLDED_TAG, so the - # attachment is told by its shape, not its field: a folded family - # word with nothing after it in its field, behind a family word not - # folded. O3's folded middles stand before the family under the - # default order, so none has one behind, and under a family-first - # order the attachment takes a trailing particle first, a folded - # middle then having a word after it, which the test above read - tok = tokens[i] - if tok.role is not Role.FAMILY or FOLDED_TAG not in tok.tags: - return False, False - attached = any(tokens[k].role is Role.FAMILY - and FOLDED_TAG not in tokens[k].tags - for k in range(i) if k not in dropped) - return attached, attached + return (j < len(tokens) + and _field_of(tokens[j], folds_middle) is field), False def _field_of(token: WorkToken, folds_middle: bool) -> Role: """The field a token stands as: its role, save a middle word the - policy folded into the family, which stands as the middle.""" + policy folded into the family, which stands as the middle; a word + P6 attached carries the folded mark too and stands as the family.""" role = token.role if token.role is not None else Role.GIVEN return (Role.MIDDLE if role is Role.FAMILY and folds_middle - and FOLDED_TAG in token.tags else role) + and FOLDED_TAG in token.tags and ATTACHED_TAG not in token.tags + else role) def _listed_suffix(text: str, lexicon: Lexicon) -> bool: @@ -389,8 +380,11 @@ def assemble(state: ParseState) -> ParsedName: slavic = PatronymicRule.EAST_SLAVIC in rules markers = (_turkic_patronymics(state, dropped) if PatronymicRule.TURKIC in rules else {}) - # the father's names of the markers read, rewritten after the walk - fathers: list[int] = [] + # each marker's father, held unbuilt until its marker is read: the + # word before it reads `patronymic` only where the marker reads as + # a name word, and building its Token twice costs frames + held: dict[int, tuple[str, Span | None, Role, frozenset[str], Reading]] = {} + fathers = frozenset(markers.values()) for i, t in enumerate(state.tokens): if i in dropped: continue @@ -520,7 +514,8 @@ def assemble(state: ParseState) -> ParsedName: # (inline, not `_field_of`: a frame per token, measured # 2026-10-29 at 7 on the reference row) field = (Role.MIDDLE if role is Role.FAMILY and folds_middle - and FOLDED_TAG in tags else role) + and FOLDED_TAG in tags and ATTACHED_TAG not in tags + else role) # rules/R9: "a particle the shipped vocabulary holds as a # patronymic", read as a particle, opens a patronymic that # takes the words after it in its field; an ending rule a @@ -545,8 +540,13 @@ def assemble(state: ParseState) -> ParsedName: i in markers or slavic and field is Role.MIDDLE and has_patronymic_ending(t.text)): kind, source = ReadingKind.PATRONYMIC, ReadingSource.SHAPE - if i in markers: - fathers.append(markers[i]) + if i in markers: + text, span, frole, ftags, freading = held.pop(markers[i]) + if (kind is ReadingKind.PATRONYMIC + and freading.kind is not ReadingKind.PATRONYMIC): + freading = _READINGS[ReadingKind.PATRONYMIC, frole, + ReadingSource.POSITION] + final[markers[i]] = Token(text, span, frole, ftags, freading) langs = _languages(t.text, kind, field) if source in _LISTED else () if (not langs and source in _LISTED and (langs := _part_languages(t.text, kind, field)) @@ -560,20 +560,26 @@ def assemble(state: ParseState) -> ParsedName: # carries the kind out, and a published tag would make a # name unequal to its own 2.x pickle (Token compares tags) tags = tags - _KIND_TAGS - final[i] = Token(t.text, t.span, role, tags, reading) + if i in fathers: + held[i] = (t.text, t.span, role, tags, reading) + else: + final[i] = Token(t.text, t.span, role, tags, reading) + if markers: + # a father built at its marker went in after the words between + # them (a nickname); `final` is read in token order + final = dict(sorted(final.items())) if attached: # rules/R9: "A trailing patronymic particle P6 attaches to the # family written before it takes that family's words as the # parent's name" (decisions/R9, #754) - fathers.extend(j for j, tok in final.items() - if tok.role is Role.FAMILY - and FOLDED_TAG not in tok.tags) - for j in fathers: - tok = final[j] - if tok.reading is not None and tok.reading.kind is not ReadingKind.PATRONYMIC: - final[j] = Token(tok.text, tok.span, tok.role, tok.tags, - _READINGS[ReadingKind.PATRONYMIC, tok.role, - ReadingSource.POSITION]) + for j, tok in final.items(): + if (tok.role is Role.FAMILY and FOLDED_TAG not in tok.tags + and tok.reading is not None + and tok.reading.kind is not ReadingKind.PATRONYMIC): + final[j] = Token(tok.text, tok.span, tok.role, tok.tags, + _READINGS[ReadingKind.PATRONYMIC, + Role.FAMILY, + ReadingSource.POSITION]) # No alphanumeric character among the SURVIVING tokens means no # name: a bare '.' or '- -' is not a person. v1 kept such input # (parse('.') -> first '.'); 2.0 empties it so bool() stays an diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 323a1c50c..4927e8a0e 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -109,7 +109,7 @@ tail_reading, trailing_titles, ) from nameparser._pipeline._state import ( - AMBIGUOUS_ACRONYM_TAG, NAME_ROLES, TITLE_AMBIGUOUS_TAG, ParseState, + AMBIGUOUS_ACRONYM_TAG, ATTACHED_TAG, NAME_ROLES, TITLE_AMBIGUOUS_TAG, ParseState, Beside, Deferred, PendingAmbiguity, PendingReading, Structure, WorkToken, _AMBIGUOUS_CREDENTIAL_TAGS, _NEVER_FLIPPED, copy_with, ) @@ -991,7 +991,7 @@ def _attach_particle_tail(pieces: Sequence[Sequence[int]], for i in run: tokens[i] = copy_with( tokens[i], role=Role.FAMILY, - tags=tokens[i].tags | {FOLDED_TAG}) + tags=tokens[i].tags | {FOLDED_TAG, ATTACHED_TAG}) def _split_name_alt(state: ParseState, tokens: Sequence[WorkToken], diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index 850d55171..df5ab6390 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -43,7 +43,7 @@ is_lone_never_given_particle, settled_by_case, ) from nameparser._pipeline._state import ( - AMBIGUOUS_ACRONYM_TAG, NAME_ROLES, + AMBIGUOUS_ACRONYM_TAG, ATTACHED_TAG, NAME_ROLES, ParseState, PendingAmbiguity, PendingReading, Structure, WorkToken, _NEVER_FLIPPED, comma_bucket, copy_with, ) @@ -755,7 +755,7 @@ def post_rules(state: ParseState) -> ParseState: for j in run: tokens[j] = copy_with( tokens[j], role=Role.FAMILY, - tags=tokens[j].tags | {FOLDED_TAG}) + tags=tokens[j].tags | {FOLDED_TAG, ATTACHED_TAG}) # recomputed for H1's reason, stated at H1: a stale index # list is the bug shape #359 fixed givens = _idx(tokens, Role.GIVEN) diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index 6b9541f16..f05cf49e9 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -116,6 +116,12 @@ class WorkToken: #: names, and dropped from the published token like the kind tags above. PATRONYMIC_TAG = "vocab:patronymic" +#: Beside FOLDED_TAG on the particles rules/P6 attaches to the family +#: written before them, at either of its sites, so rules/R9 can tell +#: the attachment from O3's middle fold, which writes FOLDED_TAG alone; +#: dropped from the published token like the kind tags above. +ATTACHED_TAG = "vocab:attached" + #: On a word of `Lexicon.titles_ambiguous` whichever way classify read #: it (rules/H9): read as a title it carries "vocab:title" too, read #: out of the title it carries this alone, which is how assign finds diff --git a/tests/v2/rules_budget.json b/tests/v2/rules_budget.json index 948bf8a8e..e5889f81a 100644 --- a/tests/v2/rules_budget.json +++ b/tests/v2/rules_budget.json @@ -57,7 +57,7 @@ "R6": {"words": 400, "then_words": 265, "because_sentences": 2, "except": 1, "precedence": 0, "accepted": 5, "accepted_words": 289}, "R7": {"words": 142, "then_words": 30, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 2, "accepted_words": 74}, "R8": {"words": 163, "then_words": 61, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 7, "accepted_words": 291}, - "R9": {"words": 377, "then_words": 123, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 11, "accepted_words": 402}, + "R9": {"words": 384, "then_words": 123, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 13, "accepted_words": 459}, "S1": {"words": 306, "then_words": 26, "because_sentences": 1, "except": 2, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S2": {"words": 657, "then_words": 119, "because_sentences": 1, "except": 0, "precedence": 7, "accepted": 3, "accepted_words": 222}, "S3": {"words": 960, "then_words": 398, "because_sentences": 3, "except": 4, "precedence": 5, "accepted": 6, "accepted_words": 343}, diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index 69898d622..a09e94417 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -8,7 +8,7 @@ from nameparser import FAMILY_FIRST, GIVEN_FIRST, Lexicon, Locale, Parser, Policy, parse from nameparser._types import ( STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Reading, Role, - Segmentation, Span, Token, + UNJOINED_TAG, Segmentation, Span, Token, ) @@ -1113,12 +1113,71 @@ def test_the_patronymic_view_reads_a_particle_or_a_rule( (Policy(name_order=FAMILY_FIRST), "Ahmad Ahmad de bin", "bin Ahmad"), # a suffix ends the run, so a family word behind it is not in it (Policy(), "bin Ahmad Jr. Hasan, Ali", "bin Ahmad"), + # P6 attaching a patronymic particle with another particle after + # it: the attached words and the family they attach to, the second + # review's D1 + (Policy(), "Bakar, Ali bin Abu", "bin Abu Bakar"), + (Policy(), "Saud, Mohammed bin Al", "bin Al Saud"), + (Policy(), "Mohamad, Mahathir bin de", "bin de Mohamad"), + (Policy(name_order=FAMILY_FIRST), "Ahmad Ali bin al", "bin al Ahmad"), + (_MAF, "Bakar, Ali bin Abu", "bin Abu Bakar"), + (Policy(), "Ahmad, Ali bin bin", "bin bin Ahmad"), + # a trailing particle P6 declines (no given word ahead of it) that + # O3 folds is no attachment: the second review's D2 + (Policy(), "Rahman, Al bin", ""), + (_MAF, "Rahman, Al bin", ""), + (Policy(name_order=FAMILY_FIRST, middle_as_family=True), + "Rahman, Al bin", ""), + (_MAF, "Ahmad, bin bin", ""), + (_MAF, "Ali, van bin", ""), + (_MAF, "Ahmad Omar, Bar Bar", ""), + (_MAF, "Ahmad, Dr. bin Bar", ""), + # a word P6 attached stands as the family under the fold, so a + # folded middle's run does not reach it + (_MAF, "Ali, Ahmad bin Ahmad Abu", "bin Ahmad"), ]) def test_the_patronymic_run_and_the_attachment_under_each_policy( policy: Policy, text: str, patronymic: str) -> None: assert Parser(policy=policy).parse(text).patronymic == patronymic +def test_middle_as_family_moves_no_patronymic_r2_does_not_move() -> None: + # O3's fold changes where a middle word renders, not what it is, so + # a name reads the same patronymic with and without it wherever R2 + # reads its particles alike: a part made only of particles is name + # words (R2's unjoined mark), and the fold can give one a base + # ('Ali, bin bin Abu' reads patronymic 'bin Abu' under the fold + # alone, rules/R9's Accepted). The words, not their order: the + # view writes the family's words as the family does. A grid of particle, comma and + # given-name shapes (2,540 texts, each under two orders, about two + # seconds on 2026-10-29) that holds P6's attachment apart from the + # fold, the second review's D2. Compared: 4,494 of the 5,080 parses, + # recorded 2026-10-29; the rest are the R2 shape above + words = ["Ahmad", "bin", "Abu", "de", "Ali,"] + texts = [" ".join(c) for n in range(2, 6) + for c in itertools.product(words, repeat=n) if "bin" in c] + assert len(texts) == 2540 + + def unjoined(pn: ParsedName) -> list[bool]: + return [UNJOINED_TAG in t.tags for t in pn.tokens] + + moved, compared = [], 0 + for order in (GIVEN_FIRST, FAMILY_FIRST): + plain = Parser(policy=Policy(name_order=order)) + folded = Parser(policy=Policy(name_order=order, middle_as_family=True)) + for text in texts: + a, b = plain.parse(text), folded.parse(text) + if unjoined(a) != unjoined(b): + continue + compared += 1 + # the same words: the fold writes the family's folded words + # first, and the view writes the family's words as it does + if sorted(a.patronymic.split()) != sorted(b.patronymic.split()): + moved.append((text, order, a.patronymic, b.patronymic)) + assert not moved, moved[:10] + assert compared == 4494 + + def test_a_turkic_marker_a_caller_lists_as_a_connective_names_no_father( ) -> None: # the marker keeps the connective reading the caller's Lexicon gives @@ -1168,3 +1227,8 @@ def test_a_patronymic_parsedname_pickled_by_2_8_0_loads_equal() -> None: assert (loaded.patronymic, now.patronymic) == ("", "bin Mohamad") assert not any(t.startswith("vocab:patronymic") for tok in now.tokens for t in tok.tags) + # nor does P6's attachment mark, on the words it attaches + attached = parse("Bakar, Ali bin Abu") + assert attached.patronymic == "bin Abu Bakar" + assert not any(t == "vocab:attached" + for tok in attached.tokens for t in tok.tags) diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 78cf07151..78f5830b9 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -87,6 +87,7 @@ "Asst. Vice Chancellor John Smith" "Attorney General of Minnesota" "Bahadur, Ram B.K." +"Bakar, Ali bin Abu" "Bard Larsen" "Beethoven Ludwig Maria van" "Beethoven Ludwig van" @@ -669,7 +670,8 @@ "Lau, Wing" "Lau, Wing Commander" "Levi, Omer Bar" -"Leyla née Aliyeva qizi" +"Leyla Vali qizi née Rasim qizi" +"Leyla née Rasim qizi" "Lloret Palol II, JA" "Lloret Palol II, Josep Antoni" "Lord Buddha Tamang" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 4ebfa2267..0dea40bbe 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -334,6 +334,7 @@ {"name": "Badurad", "given": "Badurad", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Bahadur, Ram B.K.", "given": "Ram", "middle": "B.K.", "family": "Bahadur", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "R. B. B."} {"name": "Bailey, Liberty Hyde", "given": "Liberty", "middle": "Hyde", "family": "Bailey", "initials": "L. H. B."} +{"name": "Bakar, Ali bin Abu", "given": "Ali", "family": "bin Abu Bakar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. B.", "capitalized_force": "Ali bin abu Bakar", "patronymic": "bin Abu Bakar"} {"name": "Baker (Johnson), Jenny", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} {"name": "Baker (Jr.), Jenny", "given": "Jenny", "family": "Baker", "suffix": "Jr.", "initials": "J. B.", "generation": "Jr."} {"name": "Bakker Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Bakker", "initials": "I. B."} @@ -2300,7 +2301,8 @@ {"name": "Lerch, Magda von", "given": "Magda", "family": "von Lerch", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. L."} {"name": "Leschetizky, Walter", "given": "Walter", "family": "Leschetizky", "initials": "W. L."} {"name": "Levi, Omer Bar", "given": "Omer", "family": "Bar Levi", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "O. L.", "capitalized_force": "Omer bar Levi", "patronymic": "Bar Levi"} -{"name": "Leyla née Aliyeva qizi", "family": "Leyla", "maiden": "Aliyeva qizi", "initials": "L.", "capitalized_force": "Leyla (Aliyeva Qizi)"} +{"name": "Leyla Vali qizi née Rasim qizi", "given": "Leyla", "middle": "Vali", "family": "qizi", "maiden": "Rasim qizi", "initials": "L. V. q.", "capitalized_force": "Leyla Vali Qizi (Rasim Qizi)"} +{"name": "Leyla née Rasim qizi", "family": "Leyla", "maiden": "Rasim qizi", "initials": "L.", "capitalized_force": "Leyla (Rasim Qizi)"} {"name": "Lezo, Blas de", "given": "Blas", "family": "de Lezo", "initials": "B. L."} {"name": "Liang, Shuming", "given": "Shuming", "family": "Liang", "initials": "S. L."} {"name": "Lianora Sanna", "given": "Lianora", "family": "Sanna", "initials": "L. S."} From 8ec4c457ed155c36b8389a5e7501eb1325614f80 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 20:59:09 -0700 Subject: [PATCH 5/6] Name no parent from a particle P6 attached as the whole family (4c) Behind an empty head P6 attaches the particle as the whole family; O3's fold gave it a base, and it read as an attached patronymic alone (', Siti Aminah binti' under middle_as_family). An attached particle now needs a family word not folded, the condition the shape test held. The stage headers and mechanisms.md name the attached tag; the grid test says what it cannot see. Third review fixes. Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/R9-patronymic-view.md | 4 ++-- docs/design/mechanisms.md | 4 ++-- docs/design/rules/R9-patronymic-view.md | 2 +- nameparser/_pipeline/_assemble.py | 12 ++++++++-- nameparser/_pipeline/_assign.py | 5 +++-- nameparser/_pipeline/_post_rules.py | 6 +++-- nameparser/_pipeline/_state.py | 8 ++++--- tests/v2/test_types.py | 25 ++++++++++++++++----- tools/differential/corpus_rules.jsonl | 1 + tools/differential/snapshot.jsonl | 1 + 10 files changed, 49 insertions(+), 19 deletions(-) diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md index 43540e1b2..60e02b8a5 100644 --- a/docs/design/decisions/R9-patronymic-view.md +++ b/docs/design/decisions/R9-patronymic-view.md @@ -20,6 +20,6 @@ Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's wor 5. **The report's alternative moves with the reading.** A leading patronymic particle read as a given name names `patronymic as family` where it named `particle as family` (`bin Mohamad`), through `_vocab.particle_reading`, which both of assign's particle alternatives now ask, so `Token.reading` and `Ambiguity.alternative` agree, as R8's 4b made them for `Jr`. 6. **The ending rules read a word, not only a rotation.** O1 and O2 fire on exactly three and four name words; the patronymic is written in more shapes than that (`Ivanov, Ivan Ivanovich`, `Ivan Ivanovich Ivanov`, which the rotation leaves alone), so the East Slavic arm reads a middle word carrying O1's ending, and the Turkic arm a marker in a name field with the name word before it in its comma part, wherever the rule is on. The middle is the field a patronymic stands in after a given name, and a family word carrying the ending is a surname as often as not (`Roman Abramovich`). The patterns moved from `_post_rules` to `_vocab` as `has_patronymic_ending` and `is_patronymic_marker`, which the rotations and assemble both ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION), the packs' hand copies pinned against them there. 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. - 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the fix told the attachment by its shape, a folded family particle with nothing after it in its field behind a family word not folded. The second review found that shape wrong both ways: a particle P6 attached with another after it (`Bakar, Ali bin Abu`, a real naming pattern) opened a plain run and lost the family, reading 'bin Abu', under the default policy; and after a family comma, where the family is written first, a trailing particle P6 declines (no given word ahead of it) and O3 folds read as attached (`Rahman, Al bin` under `middle_as_family` gave 'bin Rahman'). P6 now marks what it attaches (item 3), and the field of a word P6 attached is the family under the fold, so a folded middle's run stops short of it (`Ali, Ahmad bin Ahmad Abu`). (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, built once its marker is read, the token held until then. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Rasim qizi` gives 'Rasim qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. + 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the fix told the attachment by its shape, a folded family particle with nothing after it in its field behind a family word not folded. The second review found that shape wrong both ways: a particle P6 attached with another after it (`Bakar, Ali bin Abu`, a real naming pattern) opened a plain run and lost the family, reading 'bin Abu', under the default policy; and after a family comma, where the family is written first, a trailing particle P6 declines (no given word ahead of it) and O3 folds read as attached (`Rahman, Al bin` under `middle_as_family` gave 'bin Rahman'). P6 now marks what it attaches (item 3), and the field of a word P6 attached is the family under the fold, so a folded middle's run stops short of it (`Ali, Ahmad bin Ahmad Abu`). The third review found that the mark alone had dropped one condition of the shape test, a family word not folded for the attachment to name: behind an empty head (rules/C15) P6 attaches the particle as the whole family, which R2 makes name words without the fold and O3's fold gives a base, so `, Siti Aminah binti` under `middle_as_family` read patronymic 'binti'; an attached particle now names a parent only where the family holds a word P6 and O3 did not fold, and `test_middle_as_family_moves_no_patronymic_r2_does_not_move`, whose comparison skips a shape whose R2 marks differ, says that it cannot see these shapes and leaves them to its sibling's rows. (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, built once its marker is read, the token held until then. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Rasim qizi` gives 'Rasim qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. 9. **Accepted after the second review.** R2 reads a name part made only of particles as name words, and O3's fold can give such a part a base, so its particle reads as one under the fold alone (`Ali, bin bin Abu` gives 'bin Abu' under `middle_as_family`, none without); `test_middle_as_family_moves_no_patronymic_r2_does_not_move` holds the two policies to the same patronymic words wherever R2's marks agree, 4,494 of 5,080 parses of its grid. Two Turkic patronymics, one in the name and one in the maiden name, are one view (`Leyla Vali qizi née Rasim qizi`), as two nasab particles are. - - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 29 names, 6,599 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the second review's fixes: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape nine (`Mohamad, Mahathir bin` 263 to 272, its family's tokens rebuilt). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse (`_turkic_patronymics`), one per word in a name field or the maiden name (`is_patronymic_marker`, which O2's rotation also asks once of the last word), and two per marker found (`comma_bucket`), the father's token built once (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 251: one, four in the arm and one in O2, two). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 30 names, 6,600 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the second review's fixes: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 11 (`Mohamad, Mahathir bin` 263 to 274, `Bakar, Ali bin Abu` 289 to 300, its family's tokens rebuilt and its family words looked for). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse (`_turkic_patronymics`), one per word in a name field or the maiden name (`is_patronymic_marker`, which O2's rotation also asks once of the last word), and two per marker found (`comma_bucket`), the father's token built once (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 251: one, four in the arm and one in O2, two). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index c067a008b..3e114d8bb 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -28,7 +28,7 @@ This catalog converts discovery into a one-time cost — it does not remove disc **How it works.** Reordering the token tuple would break span math and reintroduce the #100 family. Parse state stays in string order; only the view reorders (rule R1, rule R3's order clause, rule O3's render clause). The consumer list is where this mechanism fails, in two opposite directions — and only ONE of them has ever shipped, which is worth keeping straight because a roster is walked differently when it is guarding against a defect than when it is guarding against a possibility. SHIPPED: a consumer that never read the tag (`initials()` walked written order through 2.0, 2.1 and 2.2 — #408, filed as an instance of RENDER-HONORS-THE-PARSE and fixed 2026-08-30). HAZARD, never released: a consumer that reads the tag where it should not (the revise strip below), which arrived with `Parser.revise` in the same commit as the test that pins it, so no version has gone out without it and no issue, decision entry or test names a defect of that shape. Adding a producer is cheap; adding a view is where the roster has to be walked. -**Lives in.** nameparser/_types.py (FOLDED_TAG, `_text_for` — every role it renders, not FAMILY alone), nameparser/_render.py (`initials`, the same partition per role), nameparser/_pipeline/_post_rules.py and _assign.py (its producers: O3's fold and P6's no-comma attachment in post_rules, P6's attachment after a family comma in assign since #613 -- every one of which re-roles to FAMILY — so FAMILY is the only role a parse can put the tag on today, and the views partitioning every role is uniformity with this contract rather than reachable behavior), and nameparser/_parser.py (`Parser.revise`, the one deliberate CONSUMER-side strip: it removes the tag from the tokens it harvests, a revised value not being allowed to inherit fold ordering) — losing that strip is this mechanism's measured hazard, a family rendering "García Gabriel Márquez". A module path and not a method name, because this roster is what a reader walks when adding a view, and a bare `Parser.revise` sends them looking in the wrong file. +**Lives in.** nameparser/_types.py (FOLDED_TAG, `_text_for` — every role it renders, not FAMILY alone), nameparser/_render.py (`initials`, the same partition per role), nameparser/_pipeline/_post_rules.py and _assign.py (its producers: O3's fold and P6's no-comma attachment in post_rules, P6's attachment after a family comma in assign since #613, the two P6 sites writing `vocab:attached` (`_state.ATTACHED_TAG`) beside it, which O3 does not, so assemble can tell the attachment from the fold for rules/R9 and drops it there -- every one of which re-roles to FAMILY — so FAMILY is the only role a parse can put the tag on today, and the views partitioning every role is uniformity with this contract rather than reachable behavior), and nameparser/_parser.py (`Parser.revise`, the one deliberate CONSUMER-side strip: it removes the tag from the tokens it harvests, a revised value not being allowed to inherit fold ordering) — losing that strip is this mechanism's measured hazard, a family rendering "García Gabriel Márquez". A module path and not a method name, because this roster is what a reader walks when adding a view, and a bare `Parser.revise` sends them looking in the wrong file. **Reach for it when.** A new rule needs "X renders before Y" and you are tempted to swap tokens. Don't swap. Tag. @@ -41,7 +41,7 @@ This catalog converts discovery into a one-time cost — it does not remove disc **How it works.** One lookup site means one answer: a stage that re-derived vocabulary facts could disagree with the stage before it. Stable tags ("particle", "conjunction", "initial") are API; "vocab:"-namespaced ones are not. -**Lives in.** nameparser/_pipeline/_classify.py (producer); consumers throughout _group/_assign/_post_rules, and nameparser/_render.py, which is not a stage but reads the same tags to decide what a view shows (#458 moved case repair's conjunction test onto the tag; initials() had read them since 2.0); the v1 facade's `_token_is_conjunction`, a consumer from #528, went with the facade in #357. +**Lives in.** nameparser/_pipeline/_classify.py (producer), the internal tag constants in nameparser/_pipeline/_state.py (among them the kind tags `vocab:generation-word`, `vocab:credential-word` and `vocab:patronymic`, which assemble reads for `Token.reading` and drops from the published token); consumers throughout _group/_assign/_post_rules, and nameparser/_render.py, which is not a stage but reads the same tags to decide what a view shows (#458 moved case repair's conjunction test onto the tag; initials() had read them since 2.0); the v1 facade's `_token_is_conjunction`, a consumer from #528, went with the facade in #357. **Reach for it when.** A stage is about to import Lexicon to ask about a word classify already saw — or a view is, which is the harder one to notice, since a view legitimately holds a Lexicon for the questions classify never answered; the view side is RENDER-HONORS-THE-PARSE, which owns it and covers the decisions this entry does not produce. diff --git a/docs/design/rules/R9-patronymic-view.md b/docs/design/rules/R9-patronymic-view.md index 31a645342..0979c392b 100644 --- a/docs/design/rules/R9-patronymic-view.md +++ b/docs/design/rules/R9-patronymic-view.md @@ -90,7 +90,7 @@ - A name part made only of particles is name words (R2), and `middle_as_family`'s fold can give such a part a base, so it reads a patronymic under the fold alone: `Ali, bin bin Abu` reads patronymic `bin Abu` with the fold and none without. - Two patronymics are one view, in the name and the maiden name alike. ``` - "Ali bin Ahmad bin Hasan" → patronymic="bin Ahmad bin Hasan" + "Siti binti Ahmad binti Hasan" → patronymic="binti Ahmad binti Hasan" "Leyla Vali qizi née Rasim qizi" [tr_az] → patronymic="Vali qizi Rasim qizi" ``` - A patronymic particle is the shipped vocabulary's, read whatever the reading's default, as R8 reads a suffix word's kind, so a word only a caller's `Lexicon` lists as a particle (`ben`, which the vocabulary excludes as a patronymic) is a plain particle. diff --git a/nameparser/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py index 225244aca..2d3bac061 100644 --- a/nameparser/_pipeline/_assemble.py +++ b/nameparser/_pipeline/_assemble.py @@ -318,13 +318,21 @@ def _patronymic_opening(state: ParseState, dropped: set[int], i: int, a parent (rules/R9), and whether it names it as P6's attachment: a particle P6 attached to the family written before it ('Mohamad, Mahathir bin', 'Bakar, Ali bin Abu') names that family, the words - attached after it with it; any other names the words after it in + attached after it with it, where the family holds a word not + folded; any other names the words after it in its field, a nickname aside, and one ending its field names nobody, a family word spelled as one ('Bar' in 'Fritz von Bar'). Asked only of a patronymic particle, so other names pay nothing.""" tokens = state.tokens if ATTACHED_TAG in tokens[i].tags: - return True, True + # the family it attaches to must hold a word of its own: behind + # an empty head P6 attaches it as the whole family (', Siti + # Aminah binti'), which O3's fold can give a base, and a + # particle with no family to name names nobody + named = any(tokens[k].role is Role.FAMILY + and FOLDED_TAG not in tokens[k].tags + for k in range(len(tokens)) if k not in dropped) + return named, named j = i + 1 while j < len(tokens) and (j in dropped or tokens[j].role is Role.NICKNAME): diff --git a/nameparser/_pipeline/_assign.py b/nameparser/_pipeline/_assign.py index 4927e8a0e..4ae9d7a3e 100644 --- a/nameparser/_pipeline/_assign.py +++ b/nameparser/_pipeline/_assign.py @@ -6,8 +6,9 @@ main segment's placement takes rather than peeling again, save where group's joins left no name word in front of it. Produces: tokens with roles set on every main-stream token, and the -folded-middle tag on the particles P6's attachment joins to the family -after a family comma (#613). +folded-middle and attached tags on the particles P6's attachment joins +to the family after a family comma (#613; the attached tag, 3.0's 4c, +tells rules/R9 the attachment from O3's fold). Reads: Policy.name_order (#270), is_suffix_lenient on the trailing piece of a two-part comma name, and Policy.script_orders (#271, which overrides it when every name piece is written wholly in one script, or diff --git a/nameparser/_pipeline/_post_rules.py b/nameparser/_pipeline/_post_rules.py index df5ab6390..0ca9d1fb4 100644 --- a/nameparser/_pipeline/_post_rules.py +++ b/nameparser/_pipeline/_post_rules.py @@ -10,8 +10,10 @@ SUFFIX role of a delimited member its writing settles after the name, rules/S13, with the SUFFIX_OR_NICKNAME report on any other member left a nickname), the stable -"joined" tag on a post-nominal continuing the entry before it, and the -ambiguity P6's attachment reports at the end of a name read +"joined" tag on a post-nominal continuing the entry before it, the +folded-middle tag on the words O3 folds and P6's no-comma attachment +joins to the family, with the attached tag on the latter (rules/R9 +tells the two apart by it), and the ambiguity P6's attachment reports at the end of a name read family-first (#467) -- after a family comma, that attachment is assign's since #613, and behind an empty part before the comma too since #892. diff --git a/nameparser/_pipeline/_state.py b/nameparser/_pipeline/_state.py index f05cf49e9..18fd677c9 100644 --- a/nameparser/_pipeline/_state.py +++ b/nameparser/_pipeline/_state.py @@ -289,10 +289,12 @@ class ParseState: SUFFIX/TITLE roles of the run a maiden take gives up (#601), AND tail_reads, S2's trailing run read once before its joins (#614); assign -> the remaining token roles AND `order`, the effective - order it read them under, AND the folded-middle tag on the - particles P6's attachment joins after a family comma (#613); + order it read them under, AND the folded-middle and attached tags + on the particles P6's attachment joins after a family comma (#613); post_rules -> roles again (a delimited member S13 settles becomes - the suffix, #810), and the ambiguity P6's no-comma attachment + the suffix, #810), the folded-middle tag on what O3 folds and the + no-comma attachment joins, with the attached tag on the latter, + and the ambiguity P6's no-comma attachment reports (#467) and SUFFIX_OR_NICKNAME on a member it leaves a nickname. Ambiguities are recorded by every stage that DECIDES one -- diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index a09e94417..dca315a3d 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -1135,6 +1135,17 @@ def test_the_patronymic_view_reads_a_particle_or_a_rule( # a word P6 attached stands as the family under the fold, so a # folded middle's run does not reach it (_MAF, "Ali, Ahmad bin Ahmad Abu", "bin Ahmad"), + # behind an empty head P6 attaches the particle as the whole + # family, which names no one, fold or no fold: the third review's + # D-A, the empty-head comma (C15) the shape test refused + (Policy(), ", Siti Aminah binti", ""), + (_MAF, ", Siti Aminah binti", ""), + (Policy(name_order=FAMILY_FIRST, middle_as_family=True), + ", Siti Aminah binti", ""), + (_MAF, ", Ali Ali bin", ""), + (_MAF, ", Dr. Ali Ahmad bin", ""), + (_MAF, ", Dr. Siti Aminah binti Jr.", ""), + (_MAF, ", Siti Aminah binti (Bob)", ""), ]) def test_the_patronymic_run_and_the_attachment_under_each_policy( policy: Policy, text: str, patronymic: str) -> None: @@ -1148,11 +1159,15 @@ def test_middle_as_family_moves_no_patronymic_r2_does_not_move() -> None: # words (R2's unjoined mark), and the fold can give one a base # ('Ali, bin bin Abu' reads patronymic 'bin Abu' under the fold # alone, rules/R9's Accepted). The words, not their order: the - # view writes the family's words as the family does. A grid of particle, comma and - # given-name shapes (2,540 texts, each under two orders, about two - # seconds on 2026-10-29) that holds P6's attachment apart from the - # fold, the second review's D2. Compared: 4,494 of the 5,080 parses, - # recorded 2026-10-29; the rest are the R2 shape above + # view writes the family's words as the family does. A grid of + # particle, comma and given-name shapes (2,540 texts, each under + # two orders, about a second on 2026-10-29); compared: 4,494 of + # the 5,080 parses, the rest the R2 shape above. What it guards: a + # word P6 attached standing as the family under the fold (10 moves + # on 021268ed). What it cannot see: a shape whose R2 marks differ + # between the policies, the second review's D2 and the third's + # empty-head D-A, and one the fold leaves alike, D1; the rows of + # the test above hold those words = ["Ahmad", "bin", "Abu", "de", "Ali,"] texts = [" ".join(c) for n in range(2, 6) for c in itertools.product(words, repeat=n) if "bin" in c] diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 78f5830b9..78dca868f 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -847,6 +847,7 @@ "Sita Kumari K.C" "Siti Hasan née binti Ali" "Siti binti Abu Bakar" +"Siti binti Ahmad binti Hasan" "Smith" "Smith (Jones)" "Smith (MA), John" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 0dea40bbe..b194b7f8d 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -3233,6 +3233,7 @@ {"name": "Sita Kumari K.C", "given": "Sita", "middle": "Kumari", "family": "K.C", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "S. K. K."} {"name": "Siti Hasan née binti Ali", "given": "Siti", "family": "Hasan", "maiden": "binti Ali", "initials": "S. H.", "capitalized_force": "Siti Hasan (Binti Ali)", "patronymic": "binti Ali"} {"name": "Siti binti Abu Bakar", "given": "Siti", "family": "binti Abu Bakar", "initials": "S. B.", "capitalized_force": "Siti binti abu Bakar", "patronymic": "binti Abu Bakar"} +{"name": "Siti binti Ahmad binti Hasan", "given": "Siti", "middle": "binti Ahmad", "family": "binti Hasan", "initials": "S. A. H.", "patronymic": "binti Ahmad binti Hasan"} {"name": "Siv Friðleifsdóttir", "given": "Siv", "family": "Friðleifsdóttir", "initials": "S. F."} {"name": "Skapti Ólafsson", "given": "Skapti", "family": "Ólafsson", "initials": "S. Ó."} {"name": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður", "order": "FAMILY_FIRST", "given": "Hr.", "middle": "Geir Jón Magnús Brynjar", "family": "Skeggjason", "suffix": "yngri, þingmaður", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "H. G. J. M. B. S.", "capitalized_force": "Skeggjason Hr. Geir Jón Magnús Brynjar Yngri, Þingmaður", "generation": "yngri", "credentials": "þingmaður"} From f38d5b223a1a3e1d402fb4cea0ca57c694133250 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Thu, 29 Oct 2026 21:11:32 -0700 Subject: [PATCH 6/6] Look for the attached family word with a loop; fourth review nits (4c) A generator resumed a frame per word. Rows for an empty head under FAMILY_FIRST_GIVEN_LAST, behind a nickname-only head and before a third comma part; R9 shows the empty-head attachment. Co-Authored-By: Claude Opus 5.5 --- docs/design/decisions/R9-patronymic-view.md | 2 +- docs/design/mechanisms.md | 2 +- docs/design/rules/R9-patronymic-view.md | 3 ++- nameparser/_pipeline/_assemble.py | 10 ++++++---- tests/v2/rules_budget.json | 2 +- tests/v2/test_types.py | 10 +++++++++- tools/differential/corpus_rules.jsonl | 1 + tools/differential/snapshot.jsonl | 1 + 8 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/design/decisions/R9-patronymic-view.md b/docs/design/decisions/R9-patronymic-view.md index 60e02b8a5..b38d9c021 100644 --- a/docs/design/decisions/R9-patronymic-view.md +++ b/docs/design/decisions/R9-patronymic-view.md @@ -22,4 +22,4 @@ Open: the Icelandic ending rule, which an `is` pack would turn on (the RFC's wor 7. **The view stays out of `as_dict()`, `--json`, `comparison_key()` and the repr**, as R8's do, and is a `render()` key and an assertable field of a rule's example lines. The snapshot records it where it is not empty (`tools/differential/snapshot.py`'s `PATRONYMIC_VIEW`, a `RECORDED_LATER` key), and `tools/differential/cldr_agreement.py` scores it on its own beside the merged `given_names`, as its PENDING_ROLES were written to once `ParsedName` had it. 8. **Review fixes (2026-10-29, the orchestrating review of #948).** (a) P6's attachment and O3's middle fold both write the folded mark, so under `middle_as_family` the first draft read the attached particle's field as the middle and dropped the exception (`Mohamad, Mahathir bin` and `Levi, Omer Bar` read no patronymic): the fix told the attachment by its shape, a folded family particle with nothing after it in its field behind a family word not folded. The second review found that shape wrong both ways: a particle P6 attached with another after it (`Bakar, Ali bin Abu`, a real naming pattern) opened a plain run and lost the family, reading 'bin Abu', under the default policy; and after a family comma, where the family is written first, a trailing particle P6 declines (no given word ahead of it) and O3 folds read as attached (`Rahman, Al bin` under `middle_as_family` gave 'bin Rahman'). P6 now marks what it attaches (item 3), and the field of a word P6 attached is the family under the fold, so a folded middle's run stops short of it (`Ali, Ahmad bin Ahmad Abu`). The third review found that the mark alone had dropped one condition of the shape test, a family word not folded for the attachment to name: behind an empty head (rules/C15) P6 attaches the particle as the whole family, which R2 makes name words without the fold and O3's fold gives a base, so `, Siti Aminah binti` under `middle_as_family` read patronymic 'binti'; an attached particle now names a parent only where the family holds a word P6 and O3 did not fold, and `test_middle_as_family_moves_no_patronymic_r2_does_not_move`, whose comparison skips a shape whose R2 marks differ, says that it cannot see these shapes and leaves them to its sibling's rows. (b) A Turkic marker a caller's `Lexicon` lists as a connective kept that reading while the word before it read `patronymic` (`Ali Ahmad Vali oglu Hasan` gave patronymic 'Vali'): the father is now read only once its marker reads as a name word, built once its marker is read, the token held until then. (c) The Turkic arm read a name field only, while the particle arm reads any: a marker in the maiden name now reads with the maiden word before it (`Leyla née Rasim qizi` gives 'Rasim qizi', `Ali née oglu` none). (d) The user docs said the ending rules fill the view under every order; the East Slavic arm reads only a middle, so it follows the order (`Ivan Ivanovich Ivanov` under FAMILY_FIRST reads given `Ivanovich` and none). (e) Each clause the review found no test for now has one that fails without it: the field test of the run's opener (`Ali bin, Ali`), the folded word left out of P6's family (`Ahmad Ahmad de bin` under FAMILY_FIRST), the suffix ending a run (`bin Ahmad Jr. Hasan, Ali`, whose family resumes behind the suffix) and the name-word test of the marker (the connective marker above); a clause requiring the attached particle to end the family was implied by the others and is gone. The first fix moved the run's field computation into a helper and cost a frame per token, seven on the reference row, caught by re-taking the frames below; it is inline again. 9. **Accepted after the second review.** R2 reads a name part made only of particles as name words, and O3's fold can give such a part a base, so its particle reads as one under the fold alone (`Ali, bin bin Abu` gives 'bin Abu' under `middle_as_family`, none without); `test_middle_as_family_moves_no_patronymic_r2_does_not_move` holds the two policies to the same patronymic words wherever R2's marks agree, 4,494 of 5,080 parses of its grid. Two Turkic patronymics, one in the name and one in the maiden name, are one view (`Leyla Vali qizi née Rasim qizi`), as two nasab particles are. - - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 30 names, 6,600 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the second review's fixes: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape 11 (`Mohamad, Mahathir bin` 263 to 274, `Bakar, Ali bin Abu` 289 to 300, its family's tokens rebuilt and its family words looked for). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse (`_turkic_patronymics`), one per word in a name field or the maiden name (`is_patronymic_marker`, which O2's rotation also asks once of the last word), and two per marker found (`comma_bucket`), the father's token built once (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 251: one, four in the arm and one in O2, two). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. + - Measured 2026-10-29 on the 4c branch over `a9884bfb`. The committed snapshot's 6,570 readings of the base: 23 gain a `patronymic` key, every one a patronymic particle's run (the snapshot parses no pack), and one row's alternative moves (`bin Stromae Elizabeth Windsor Maria Teresa MP` under FAMILY_FIRST, `particle as family` to `patronymic as family`); no field moves; rules/R9's examples add 31 names, 6,601 readings in all. Recompute: compare each row of `snapshot.jsonl` with its base row, `patronymic` and `alternatives` set apart. The agreement records gain only `patronymic` entries, each a patronymic stated and not read, none read where the source states none or another: Wikidata 334 (its `patronymic-ru` 126, `patronymic-ri` 41, `patronymic-su` 62, `patronymic-is` 100 and `patronymic-my` 5), the native-script source 17 (`uk-ua` 14, `kk-kz` 3), conventions 6. Under the RU pack, Wikidata's `patronymic-ru` agrees 126 of 126, `patronymic-ri` 41 of 41 and `patronymic-su` 61 of 62, the miss `Оксана Сергіївна Баюл`'s Ukrainian ending; `patronymic-my` agrees 4 of 9 in the default, the five misses written without `bin`. Recompute: parse each `wikidata_fields.jsonl` row stating a patronymic with `cldr_agreement.parse_rendering`, with and without `RU`, and compare `got_roles(...)["patronymic"]` with `expected_roles(...)["patronymic"]` by `same`. Frames per parse (`sys.setprofile` call events, a warm parse), re-taken after the second review's fixes: the reference row `Dr. Juan Q. Xavier de la Vega III` 369 and `John Smith` 153 on both trees under the default policy; a patronymic particle adds two (`Mahathir bin Mohamad` 217 to 219), P6's attached shape nine (`Mohamad, Mahathir bin` 263 to 272, `Bakar, Ali bin Abu` 289 to 298, its family's tokens rebuilt; the look for a family word not folded is a loop, a generator there costing two more and one per word behind an empty head). The opt-in rules pay per word, which is frame question (1) of root AGENTS.md, a cost that scales with the input, recorded here and guarded by the `patronymic_rules` row of `tests/v2/test_benchmark.py`'s `_POLICY_SHAPES`: RU one per middle word read as a name (`Ivan Ivanovich Ivanov` 211 to 213, `John Paul George Ringo Smith` 270 to 273, the reference row 369 to 370), TR_AZ one per parse (`_turkic_patronymics`), one per word in a name field or the maiden name (`is_patronymic_marker`, which O2's rotation also asks once of the last word), and two per marker found (`comma_bucket`), the father's token built once (`John Smith` 153 to 156, the reference row 369 to 376, `Ali Ahmad Vali oglu` 243 to 251: one, four in the arm and one in O2, two). The ending and marker tests are `_vocab`'s shared predicates rather than an inline pre-check, the rules being opt-in and their per-word cost one frame. diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 3e114d8bb..cc166b9d8 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -28,7 +28,7 @@ This catalog converts discovery into a one-time cost — it does not remove disc **How it works.** Reordering the token tuple would break span math and reintroduce the #100 family. Parse state stays in string order; only the view reorders (rule R1, rule R3's order clause, rule O3's render clause). The consumer list is where this mechanism fails, in two opposite directions — and only ONE of them has ever shipped, which is worth keeping straight because a roster is walked differently when it is guarding against a defect than when it is guarding against a possibility. SHIPPED: a consumer that never read the tag (`initials()` walked written order through 2.0, 2.1 and 2.2 — #408, filed as an instance of RENDER-HONORS-THE-PARSE and fixed 2026-08-30). HAZARD, never released: a consumer that reads the tag where it should not (the revise strip below), which arrived with `Parser.revise` in the same commit as the test that pins it, so no version has gone out without it and no issue, decision entry or test names a defect of that shape. Adding a producer is cheap; adding a view is where the roster has to be walked. -**Lives in.** nameparser/_types.py (FOLDED_TAG, `_text_for` — every role it renders, not FAMILY alone), nameparser/_render.py (`initials`, the same partition per role), nameparser/_pipeline/_post_rules.py and _assign.py (its producers: O3's fold and P6's no-comma attachment in post_rules, P6's attachment after a family comma in assign since #613, the two P6 sites writing `vocab:attached` (`_state.ATTACHED_TAG`) beside it, which O3 does not, so assemble can tell the attachment from the fold for rules/R9 and drops it there -- every one of which re-roles to FAMILY — so FAMILY is the only role a parse can put the tag on today, and the views partitioning every role is uniformity with this contract rather than reachable behavior), and nameparser/_parser.py (`Parser.revise`, the one deliberate CONSUMER-side strip: it removes the tag from the tokens it harvests, a revised value not being allowed to inherit fold ordering) — losing that strip is this mechanism's measured hazard, a family rendering "García Gabriel Márquez". A module path and not a method name, because this roster is what a reader walks when adding a view, and a bare `Parser.revise` sends them looking in the wrong file. +**Lives in.** nameparser/_types.py (FOLDED_TAG, `_text_for` — every role it renders, not FAMILY alone), nameparser/_render.py (`initials`, the same partition per role), nameparser/_pipeline/_post_rules.py and _assign.py (its producers: O3's fold and P6's no-comma attachment in post_rules, P6's attachment after a family comma in assign since #613 -- every one of which re-roles to FAMILY — so FAMILY is the only role a parse can put the tag on today, and the views partitioning every role is uniformity with this contract rather than reachable behavior), and nameparser/_parser.py (`Parser.revise`, the one deliberate CONSUMER-side strip: it removes the tag from the tokens it harvests, a revised value not being allowed to inherit fold ordering) — losing that strip is this mechanism's measured hazard, a family rendering "García Gabriel Márquez". A module path and not a method name, because this roster is what a reader walks when adding a view, and a bare `Parser.revise` sends them looking in the wrong file. The two P6 sites also write `vocab:attached` (`_state.ATTACHED_TAG`) beside it, which O3 does not, so assemble can tell the attachment from the fold for rules/R9; assemble drops it from the published token. **Reach for it when.** A new rule needs "X renders before Y" and you are tempted to swap tokens. Don't swap. Tag. diff --git a/docs/design/rules/R9-patronymic-view.md b/docs/design/rules/R9-patronymic-view.md index 0979c392b..b87711559 100644 --- a/docs/design/rules/R9-patronymic-view.md +++ b/docs/design/rules/R9-patronymic-view.md @@ -44,11 +44,12 @@ ``` "Fritz Freiherr und von Bar" → patronymic="" ``` -- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, with any word P6 attached after it, the view writing them in the family's order, under `middle_as_family` too. +- **A trailing patronymic particle P6 attaches to the family written before it** takes that family's words as the parent's name, with any word P6 attached after it, the view writing them in the family's order, under `middle_as_family` too; behind an empty head the particle is the whole family and names no one. ``` "Mohamad, Mahathir bin" → patronymic="bin Mohamad" "Levi, Omer Bar" → patronymic="Bar Levi" "Bakar, Ali bin Abu" → patronymic="bin Abu Bakar" + ", Siti Aminah binti" → patronymic="" ``` - **A nickname inside the parent's name** is skipped, not a word of the view. ``` diff --git a/nameparser/_pipeline/_assemble.py b/nameparser/_pipeline/_assemble.py index 2d3bac061..116a844cc 100644 --- a/nameparser/_pipeline/_assemble.py +++ b/nameparser/_pipeline/_assemble.py @@ -329,10 +329,12 @@ def _patronymic_opening(state: ParseState, dropped: set[int], i: int, # an empty head P6 attaches it as the whole family (', Siti # Aminah binti'), which O3's fold can give a base, and a # particle with no family to name names nobody - named = any(tokens[k].role is Role.FAMILY - and FOLDED_TAG not in tokens[k].tags - for k in range(len(tokens)) if k not in dropped) - return named, named + # (a loop, not a generator, which would resume a frame per word) + for k, other in enumerate(tokens): + if (other.role is Role.FAMILY and FOLDED_TAG not in other.tags + and k not in dropped): + return True, True + return False, False j = i + 1 while j < len(tokens) and (j in dropped or tokens[j].role is Role.NICKNAME): diff --git a/tests/v2/rules_budget.json b/tests/v2/rules_budget.json index e5889f81a..8643d38f4 100644 --- a/tests/v2/rules_budget.json +++ b/tests/v2/rules_budget.json @@ -57,7 +57,7 @@ "R6": {"words": 400, "then_words": 265, "because_sentences": 2, "except": 1, "precedence": 0, "accepted": 5, "accepted_words": 289}, "R7": {"words": 142, "then_words": 30, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 2, "accepted_words": 74}, "R8": {"words": 163, "then_words": 61, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 7, "accepted_words": 291}, - "R9": {"words": 384, "then_words": 123, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 13, "accepted_words": 459}, + "R9": {"words": 398, "then_words": 123, "because_sentences": 3, "except": 5, "precedence": 0, "accepted": 13, "accepted_words": 459}, "S1": {"words": 306, "then_words": 26, "because_sentences": 1, "except": 2, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S2": {"words": 657, "then_words": 119, "because_sentences": 1, "except": 0, "precedence": 7, "accepted": 3, "accepted_words": 222}, "S3": {"words": 960, "then_words": 398, "because_sentences": 3, "except": 4, "precedence": 5, "accepted": 6, "accepted_words": 343}, diff --git a/tests/v2/test_types.py b/tests/v2/test_types.py index dca315a3d..38a4d9e98 100644 --- a/tests/v2/test_types.py +++ b/tests/v2/test_types.py @@ -5,7 +5,10 @@ import pytest -from nameparser import FAMILY_FIRST, GIVEN_FIRST, Lexicon, Locale, Parser, Policy, parse +from nameparser import ( + FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, Lexicon, Locale, Parser, + Policy, parse, +) from nameparser._types import ( STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Reading, Role, UNJOINED_TAG, Segmentation, Span, Token, @@ -1146,6 +1149,11 @@ def test_the_patronymic_view_reads_a_particle_or_a_rule( (_MAF, ", Dr. Ali Ahmad bin", ""), (_MAF, ", Dr. Siti Aminah binti Jr.", ""), (_MAF, ", Siti Aminah binti (Bob)", ""), + (Policy(name_order=FAMILY_FIRST_GIVEN_LAST, middle_as_family=True), + ", Siti Aminah binti", ""), + # a nickname-only head, and a part past the second comma + (_MAF, "(Bob), Siti Aminah binti", ""), + (_MAF, ", Siti Aminah binti, Hasan", ""), ]) def test_the_patronymic_run_and_the_attachment_under_each_policy( policy: Policy, text: str, patronymic: str) -> None: diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 78dca868f..7d4a4f45d 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -25,6 +25,7 @@ ", PhD" ", Sir John" ", Sir Jones vd" +", Siti Aminah binti" ".," "1. née Smith" "A.B. Vajpayee" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index b194b7f8d..b18fccd22 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -32,6 +32,7 @@ {"name": ", PhD", "suffix": "PhD"} {"name": ", Sir John", "title": "Sir", "given": "John", "initials": "J."} {"name": ", Sir Jones vd", "title": "Sir", "given": "Jones", "family": "vd", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. v.", "capitalized_force": "Sir Jones Vd"} +{"name": ", Siti Aminah binti", "given": "Siti", "middle": "Aminah", "family": "binti", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "S. A. b.", "capitalized_force": "Siti Aminah Binti"} {"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."}